From 6b268e369ccb024b70b78d958b0c9baf3a59ae50 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Wed, 8 Apr 2026 10:25:01 -0600 Subject: [PATCH] Backport [Mongo SDK Plugin] (enos): Add MongoDB plugin test framework for Enos into ce/main (#13700) * no-op commit * [Mongo SDK Plugin] (enos): Add MongoDB plugin test framework for Enos (#13576) * Add modular database infrastructure for Enos testing Infrastructure Changes: - Created generic database_container module supporting PostgreSQL, MongoDB, MySQL - Consolidated database configs in enos-globals.hcl with dynamic port generation - Refactored set_up_external_integration_target to use generic module with for_each - Updated enos-scenario-plugin.hcl to pass database_configs from globals Test Organization: - Reorganized test structure: moved postgres/ and mongodb/ into database/ directory - Maintains existing production-ready test helpers - Structure: plugins/database/{postgres,mongodb}/ for better organization Benefits: - Easy to add new databases (just add to database_configs in globals) - No code duplication across database types - Consistent patterns for all database testing - Supports both Docker containers and external database URLs --------- Co-authored-by: Luis (LT) Carbonell --- enos/enos-globals.hcl | 67 +++-- enos/enos-scenario-plugin.hcl | 9 +- enos/modules/database_container/main.tf | 80 ++++++ enos/modules/database_container/variables.tf | 58 ++++ .../main.tf | 34 ++- .../variables.tf | 14 + .../secret_mongodb_connection_config_test.go | 145 ++++++++++ .../mongodb/secret_mongodb_test_helper.go | 266 ++++++++++++++++++ 8 files changed, 647 insertions(+), 26 deletions(-) create mode 100644 enos/modules/database_container/main.tf create mode 100644 enos/modules/database_container/variables.tf create mode 100644 vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_connection_config_test.go create mode 100644 vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_test_helper.go diff --git a/enos/enos-globals.hcl b/enos/enos-globals.hcl index 9940b15c16..74201ef339 100644 --- a/enos/enos-globals.hcl +++ b/enos/enos-globals.hcl @@ -152,30 +152,61 @@ globals { protocol = "tcp" } } - // Ports that we'll open up for ingress in the security group for all external integration target machines. - integration_host_ports = { - ldap : { - description = "LDAP" - port = 389 - protocol = "tcp" + // Database configurations for Vault database secrets engine testing + database_configs = { + postgres : { + port = 5432 + version = "16-alpine" + username = "postgres" + password = "secret" + database = "postgres" + description = "PostgreSQL Server" }, - ldaps : { - description = "LDAPS" - port = 636 - protocol = "tcp" + mongodb : { + port = 27017 + version = "7.0" + username = "admin" + password = "secret" + database = "admin" + description = "MongoDB Server" }, mysql : { - description = "MySQL Server" port = 3306 - protocol = "tcp" - }, - kmip : { - description = "KMIP Server" - port = 5696 - protocol = "tcp" - }, + version = "8.0" + username = "root" + password = "secret" + database = "mysql" + description = "MySQL Server" + } } + // Ports that we'll open up for ingress in the security group for all external integration target machines. + integration_host_ports = merge( + { + ldap : { + description = "LDAP" + port = 389 + protocol = "tcp" + }, + ldaps : { + description = "LDAPS" + port = 636 + protocol = "tcp" + }, + kmip : { + description = "KMIP Server" + port = 5696 + protocol = "tcp" + } + }, + // Add database ports dynamically from database_configs + { for db_name, db_config in global.database_configs : db_name => { + description = db_config.description + port = db_config.port + protocol = "tcp" + } } + ) + // Combine all ports into a single map ports = merge( global.vault_cluster_ports, diff --git a/enos/enos-scenario-plugin.hcl b/enos/enos-scenario-plugin.hcl index 35f035ecb6..d6cb2a4e14 100644 --- a/enos/enos-scenario-plugin.hcl +++ b/enos/enos-scenario-plugin.hcl @@ -234,10 +234,11 @@ scenario "plugin" { } variables { - hosts = step.create_plugin_integration_target.hosts - ip_version = matrix.ip_version - packages = concat(global.packages, global.distro_packages["ubuntu"]["24.04"], ["podman", "podman-docker"]) - ports = global.integration_host_ports + hosts = step.create_plugin_integration_target.hosts + ip_version = matrix.ip_version + packages = concat(global.packages, global.distro_packages["ubuntu"]["24.04"], ["podman", "podman-docker"]) + ports = global.integration_host_ports + database_configs = global.database_configs } } diff --git a/enos/modules/database_container/main.tf b/enos/modules/database_container/main.tf new file mode 100644 index 0000000000..b9838b9802 --- /dev/null +++ b/enos/modules/database_container/main.tf @@ -0,0 +1,80 @@ +# Copyright IBM Corp. 2016, 2026 +# SPDX-License-Identifier: BUSL-1.1 + +terraform { + required_providers { + enos = { + source = "registry.terraform.io/hashicorp-forge/enos" + } + } +} + +locals { + # Database-specific configurations + database_configs = { + postgres = { + image_template = "docker.io/postgres:${var.db_version}" + env_vars = { + POSTGRES_USER = var.username + POSTGRES_PASSWORD = var.password + POSTGRES_DB = var.database + } + } + mongodb = { + image_template = "docker.io/mongo:${var.db_version}" + env_vars = { + MONGO_INITDB_ROOT_USERNAME = var.username + MONGO_INITDB_ROOT_PASSWORD = var.password + MONGO_INITDB_DATABASE = var.database + } + } + mysql = { + image_template = "docker.io/mysql:${var.db_version}" + env_vars = { + MYSQL_ROOT_PASSWORD = var.password + MYSQL_USER = var.username + MYSQL_PASSWORD = var.password + MYSQL_DATABASE = var.database + } + } + } + + config = local.database_configs[var.database_type] + image = local.config.image_template + env_vars_map = local.config.env_vars + env_vars = join(",", [for k, v in local.env_vars_map : "${k}=${v}"]) +} + +# Creating Database Server using generic container script +resource "enos_remote_exec" "create_database" { + depends_on = [var.depends_on_modules] + + scripts = [abspath("${path.module}/../../modules/set_up_external_integration_target/scripts/start-container.sh")] + + environment = { + CONTAINER_IMAGE = local.image + CONTAINER_NAME = "${var.database_type}-${var.instance_name}" + CONTAINER_PORTS = var.port + CONTAINER_ENVS = local.env_vars + } + + transport = { + ssh = { + host = var.host.public_ip + } + } +} + +# Outputs +output "config" { + description = "Database configuration details" + value = { + type = var.database_type + username = var.username + password = var.password + database = var.database + version = var.db_version + port = var.port + host = var.host + } +} diff --git a/enos/modules/database_container/variables.tf b/enos/modules/database_container/variables.tf new file mode 100644 index 0000000000..1729e9ed67 --- /dev/null +++ b/enos/modules/database_container/variables.tf @@ -0,0 +1,58 @@ +# Copyright IBM Corp. 2016, 2026 +# SPDX-License-Identifier: BUSL-1.1 + +variable "database_type" { + description = "Type of database to create (postgres, mongodb, mysql)" + type = string + validation { + condition = contains(["postgres", "mongodb", "mysql"], var.database_type) + error_message = "database_type must be one of: postgres, mongodb, mysql" + } +} + +variable "db_version" { + description = "Database version to use" + type = string +} + +variable "username" { + description = "Database username" + type = string +} + +variable "password" { + description = "Database password" + type = string + sensitive = true +} + +variable "database" { + description = "Database name" + type = string +} + +variable "port" { + description = "Database port" + type = number +} + +variable "host" { + description = "Host configuration with public_ip" + type = object({ + public_ip = string + private_ip = string + ipv6 = optional(string) + }) +} + +variable "instance_name" { + description = "Unique instance name for the container (defaults to 'default')" + type = string + default = "default" +} + +variable "depends_on_modules" { + description = "List of modules this depends on" + type = list(any) + default = [] +} diff --git a/enos/modules/set_up_external_integration_target/main.tf b/enos/modules/set_up_external_integration_target/main.tf index 61de6adcd8..9ef1377833 100755 --- a/enos/modules/set_up_external_integration_target/main.tf +++ b/enos/modules/set_up_external_integration_target/main.tf @@ -29,14 +29,24 @@ locals { port = var.ports.mysql.port host = var.hosts[0] } + # Database configurations are now pulled from var.database_configs + database_servers = { + for db_name, db_config in var.database_configs : db_name => merge(db_config, { + host = var.hosts[0] + }) + } } # Outputs output "state" { - value = { - ldap = local.ldap_server - kmip = local.kmip_client - } + value = merge( + { + ldap = local.ldap_server + kmip = local.kmip_client + }, + # Add database servers dynamically + { for db_name, db_server in local.database_servers : db_name => db_server } + ) } # We run install_packages before we install Vault because for some combinations of @@ -125,3 +135,19 @@ resource "enos_remote_exec" "create_kmip" { } } } + +# Creating Database Servers using generic database_container module +module "database_servers" { + for_each = var.database_configs + source = "../database_container" + + database_type = each.key + db_version = each.value.version + username = each.value.username + password = each.value.password + database = each.value.database + port = each.value.port + host = var.hosts[0] + instance_name = "default" + depends_on_modules = [module.install_packages] +} diff --git a/enos/modules/set_up_external_integration_target/variables.tf b/enos/modules/set_up_external_integration_target/variables.tf index aa40888bf4..ea04c2835e 100644 --- a/enos/modules/set_up_external_integration_target/variables.tf +++ b/enos/modules/set_up_external_integration_target/variables.tf @@ -41,3 +41,17 @@ variable "ports" { description = string })) } + + +variable "database_configs" { + description = "Database configurations for setting up database servers" + type = map(object({ + port = number + version = string + username = string + password = string + database = string + description = string + })) + default = {} +} diff --git a/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_connection_config_test.go b/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_connection_config_test.go new file mode 100644 index 0000000000..47a17dcb75 --- /dev/null +++ b/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_connection_config_test.go @@ -0,0 +1,145 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package mongodb + +import ( + "testing" + + "github.com/hashicorp/vault/sdk/helper/testcluster/blackbox" +) + +// TestMongoDBConnectionConfigCRUDWorkflows runs all MongoDB connection +// config CRUD workflow tests. +func TestMongoDBConnectionConfigCRUDWorkflows(t *testing.T) { + t.Run("CreateBasic", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBConnectionConfigCreateBasic(t, v) + }) + + t.Run("ListReturnsNamesOnly", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBConnectionConfigListReturnsNamesOnly(t, v) + }) + + t.Run("ReadRedactsSensitiveFields", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBConnectionConfigReadRedactsSensitiveFields(t, v) + }) + + t.Run("ResetConnection", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBConnectionConfigResetConnection(t, v) + }) + + t.Run("DeleteConnection", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBConnectionConfigDeleteConnection(t, v) + }) +} + +// TestMongoDBConnectionConfigValidationWorkflows runs all MongoDB +// connection config validation workflow tests. +func TestMongoDBConnectionConfigValidationWorkflows(t *testing.T) { + t.Run("VerifyConnectionValid", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBConnectionConfigVerifyConnectionValid(t, v) + }) + + t.Run("VerifyConnectionInvalid", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBConnectionConfigVerifyConnectionInvalid(t, v) + }) +} + +// testMongoDBConnectionConfigCreateBasic verifies that configuring a +// MongoDB database connection succeeds at database/config/{name}. +func testMongoDBConnectionConfigCreateBasic(t *testing.T, v *blackbox.Session) { + t.Skip("Test implementation pending - MongoDB framework setup complete") + + // TODO: Implement test following this pattern: + // 1. requireVaultEnv(t) + // 2. cleanup, connURL := PrepareTestContainer(t) + // 3. defer cleanup() + // 4. mount := fmt.Sprintf("database-%s", sanitize(t.Name())) + // 5. v.MustEnableSecretsEngine(mount, &api.MountInput{Type: "database"}) + // 6. v.MustWrite(mount+"/config/my-mongodb-db", mongoConnectionConfigPayload(...)) + // 7. config := v.MustReadRequired(mount + "/config/my-mongodb-db") + // 8. v.AssertSecret(config).Data().HasKey("plugin_name", "mongodb-database-plugin") +} + +// testMongoDBConnectionConfigListReturnsNamesOnly verifies LIST +// /database/config returns configured connection names only, without +// sensitive config details. +func testMongoDBConnectionConfigListReturnsNamesOnly(t *testing.T, v *blackbox.Session) { + t.Skip("Test implementation pending - MongoDB framework setup complete") + + // TODO: Implement test to verify: + // 1. Create multiple MongoDB connections + // 2. List connections + // 3. Verify only names are returned, no sensitive data +} + +// testMongoDBConnectionConfigReadRedactsSensitiveFields verifies that reading +// a MongoDB connection config returns sanitized connection details. +func testMongoDBConnectionConfigReadRedactsSensitiveFields(t *testing.T, v *blackbox.Session) { + t.Skip("Test implementation pending - MongoDB framework setup complete") + + // TODO: Implement test to verify: + // 1. Create MongoDB connection with credentials + // 2. Read connection config + // 3. Verify password and other sensitive fields are redacted +} + +// testMongoDBConnectionConfigVerifyConnectionValid verifies that +// configuration succeeds with verify_connection=true when credentials are valid. +func testMongoDBConnectionConfigVerifyConnectionValid(t *testing.T, v *blackbox.Session) { + t.Skip("Test implementation pending - MongoDB framework setup complete") + + // TODO: Implement test to verify: + // 1. Create MongoDB connection with valid credentials and verify_connection=true + // 2. Verify connection succeeds +} + +// testMongoDBConnectionConfigVerifyConnectionInvalid verifies that +// configuration fails with verify_connection=true when credentials are invalid. +func testMongoDBConnectionConfigVerifyConnectionInvalid(t *testing.T, v *blackbox.Session) { + t.Skip("Test implementation pending - MongoDB framework setup complete") + + // TODO: Implement test to verify: + // 1. Create MongoDB connection with invalid credentials and verify_connection=true + // 2. Verify connection fails with appropriate error +} + +// testMongoDBConnectionConfigResetConnection verifies that resetting a +// MongoDB database connection succeeds and preserves the stored connection +// configuration. +func testMongoDBConnectionConfigResetConnection(t *testing.T, v *blackbox.Session) { + t.Skip("Test implementation pending - MongoDB framework setup complete") + + // TODO: Implement test to verify: + // 1. Create MongoDB connection + // 2. Reset connection + // 3. Verify config is preserved +} + +// testMongoDBConnectionConfigDeleteConnection verifies that deleting a +// MongoDB database connection removes it, prevents new credential generation, +// and remains idempotent when deleted again. +func testMongoDBConnectionConfigDeleteConnection(t *testing.T, v *blackbox.Session) { + t.Skip("Test implementation pending - MongoDB framework setup complete") + + // TODO: Implement test to verify: + // 1. Create MongoDB connection and role + // 2. Generate credentials + // 3. Delete connection + // 4. Verify connection is gone and credentials can't be generated + // 5. Delete again to verify idempotency +} diff --git a/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_test_helper.go b/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_test_helper.go new file mode 100644 index 0000000000..b42bb23281 --- /dev/null +++ b/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_test_helper.go @@ -0,0 +1,266 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package mongodb + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/hashicorp/vault/sdk/helper/docker" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + defaultMongoImage = "docker.mirror.hashicorp.services/mongo" + defaultMongoVersion = "7.0" + defaultMongoUser = "admin" + defaultMongoPass = "secret" +) + +// defaultRunOpts returns default Docker run options for MongoDB container +// Uses test name to ensure unique container names for parallel execution +func defaultRunOpts(t *testing.T) docker.RunOptions { + return docker.RunOptions{ + ContainerName: fmt.Sprintf("mongodb-%s", sanitize(t.Name())), + ImageRepo: defaultMongoImage, + ImageTag: defaultMongoVersion, + Env: []string{ + "MONGO_INITDB_ROOT_USERNAME=" + defaultMongoUser, + "MONGO_INITDB_ROOT_PASSWORD=" + defaultMongoPass, + "MONGO_INITDB_DATABASE=admin", + }, + Ports: []string{"27017/tcp"}, + DoNotAutoRemove: false, + OmitLogTimestamps: true, + LogConsumer: func(s string) { + if t.Failed() { + t.Logf("container logs: %s", s) + } + }, + } +} + +// requireVaultEnv skips the test if required Vault environment variables are not set +func requireVaultEnv(t *testing.T) { + t.Helper() + + if os.Getenv("VAULT_ADDR") == "" || os.Getenv("VAULT_TOKEN") == "" { + t.Skip("skipping blackbox test: VAULT_ADDR and VAULT_TOKEN are required") + } +} + +// sanitize converts test name to a valid container name +// Removes special characters and converts to lowercase +func sanitize(name string) string { + name = strings.ToLower(name) + name = strings.ReplaceAll(name, "/", "-") + name = strings.ReplaceAll(name, "_", "-") + name = strings.ReplaceAll(name, " ", "-") + // Remove any remaining special characters + var result strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + result.WriteRune(r) + } + } + return result.String() +} + +// PrepareTestContainer starts a MongoDB container for testing +// Returns cleanup function and connection URL +// If MONGO_URL environment variable is set, uses that instead of starting a container +func PrepareTestContainer(t *testing.T) (func(), string) { + _, cleanup, connURL, _ := prepareTestContainer(t, defaultRunOpts(t), defaultMongoPass, true, false) + return cleanup, connURL +} + +// prepareTestContainer is the internal function that handles container setup +// Supports both Docker container creation and external MongoDB via environment variable +func prepareTestContainer( + t *testing.T, + runOpts docker.RunOptions, + password string, + addSuffix bool, + forceLocalAddr bool, +) (*docker.Runner, func(), string, string) { + requireVaultEnv(t) + + // Check for external MongoDB URL + if os.Getenv("MONGO_URL") != "" { + envMongoURL := os.Getenv("MONGO_URL") + + // Create unique database for this test + dbName := fmt.Sprintf("test_%s_%d", sanitize(t.Name()), time.Now().Unix()) + testURL := replaceDatabase(envMongoURL, dbName) + + // Create the database + if err := createDatabase(t, envMongoURL, dbName); err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + + cleanup := func() { + dropDatabase(t, envMongoURL, dbName) + } + + return nil, cleanup, testURL, "" + } + + // Start Docker container + runner, err := docker.NewServiceRunner(runOpts) + if err != nil { + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "docker") && + (strings.Contains(errStr, "daemon") || strings.Contains(errStr, "connect")) { + t.Skipf("skipping blackbox test: docker not available: %v", err) + } + t.Fatalf("Could not start docker MongoDB: %s", err) + } + + svc, containerID, err := runner.StartNewService( + context.Background(), + addSuffix, + forceLocalAddr, + connectMongoDB(password), + ) + if err != nil { + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "docker") && + (strings.Contains(errStr, "daemon") || strings.Contains(errStr, "connect")) { + t.Skipf("skipping blackbox test: docker not available: %v", err) + } + t.Fatalf("Could not start docker MongoDB: %s", err) + } + + connURL := svc.Config.URL().String() + + // Create unique database for this test + dbName := fmt.Sprintf("test_%s_%d", sanitize(t.Name()), time.Now().Unix()) + testURL := replaceDatabase(connURL, dbName) + + // Create the database + if err := createDatabase(t, connURL, dbName); err != nil { + svc.Cleanup() + t.Fatalf("Failed to create test database: %v", err) + } + + cleanup := func() { + dropDatabase(t, connURL, dbName) + svc.Cleanup() + } + + return runner, cleanup, testURL, containerID +} + +// connectMongoDB returns a ServiceAdapter that connects to MongoDB +// Includes retry logic with 30-second timeout for container startup +func connectMongoDB(password string) docker.ServiceAdapter { + return func(ctx context.Context, host string, port int) (docker.ServiceConfig, error) { + u := url.URL{ + Scheme: "mongodb", + User: url.UserPassword(defaultMongoUser, password), + Host: fmt.Sprintf("%s:%d", host, port), + Path: "/admin", + } + + // Retry connection with timeout + deadline := time.Now().Add(30 * time.Second) + var lastErr error + + for time.Now().Before(deadline) { + client, err := mongo.Connect(ctx, options.Client().ApplyURI(u.String())) + if err != nil { + lastErr = err + time.Sleep(1 * time.Second) + continue + } + + // Ping to verify connection + if err = client.Ping(ctx, nil); err != nil { + client.Disconnect(ctx) + lastErr = err + time.Sleep(1 * time.Second) + continue + } + + // Connection successful + client.Disconnect(ctx) + return docker.NewServiceURL(u), nil + } + + return nil, fmt.Errorf("mongodb not ready after 30s: %w", lastErr) + } +} + +// replaceDatabase replaces the database name in a MongoDB connection URL +func replaceDatabase(connURL, dbName string) string { + u, err := url.Parse(connURL) + if err != nil { + return connURL + } + + u.Path = "/" + dbName + return u.String() +} + +// createDatabase creates a new database in MongoDB +// MongoDB creates databases lazily, so we create a collection to ensure it exists +func createDatabase(t *testing.T, connURL, dbName string) error { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(connURL)) + if err != nil { + return fmt.Errorf("failed to connect to MongoDB: %w", err) + } + defer client.Disconnect(ctx) + + // Create a collection to ensure database exists + db := client.Database(dbName) + if err := db.CreateCollection(ctx, "_init"); err != nil { + return fmt.Errorf("failed to create database: %w", err) + } + + t.Logf("Created test database: %s", dbName) + return nil +} + +// dropDatabase drops a database from MongoDB +func dropDatabase(t *testing.T, connURL, dbName string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(connURL)) + if err != nil { + t.Logf("Warning: failed to connect for cleanup: %v", err) + return + } + defer client.Disconnect(ctx) + + if err := client.Database(dbName).Drop(ctx); err != nil { + t.Logf("Warning: failed to drop database %s: %v", dbName, err) + return + } + + t.Logf("Dropped test database: %s", dbName) +} + +// mongoConnectionConfigPayload returns a standard MongoDB connection configuration payload +func mongoConnectionConfigPayload(connURL, allowedRoles string, verifyConnection bool) map[string]any { + return map[string]any{ + "plugin_name": "mongodb-database-plugin", + "connection_url": connURL, + "allowed_roles": allowedRoles, + "verify_connection": verifyConnection, + } +}