diff --git a/enos/modules/database_container/main.tf b/enos/modules/database_container/main.tf index b9838b9802..68209c6048 100644 --- a/enos/modules/database_container/main.tf +++ b/enos/modules/database_container/main.tf @@ -27,6 +27,7 @@ locals { MONGO_INITDB_ROOT_PASSWORD = var.password MONGO_INITDB_DATABASE = var.database } + args = "--bind_ip_all" } mysql = { image_template = "docker.io/mysql:${var.db_version}" @@ -43,6 +44,7 @@ locals { 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}"]) + args = try(local.config.args, "") } # Creating Database Server using generic container script @@ -56,6 +58,7 @@ resource "enos_remote_exec" "create_database" { CONTAINER_NAME = "${var.database_type}-${var.instance_name}" CONTAINER_PORTS = var.port CONTAINER_ENVS = local.env_vars + CONTAINER_ARGS = local.args } transport = { diff --git a/enos/modules/vault_run_blackbox_test/main.tf b/enos/modules/vault_run_blackbox_test/main.tf index b6ab781ddd..bb31e24530 100644 --- a/enos/modules/vault_run_blackbox_test/main.tf +++ b/enos/modules/vault_run_blackbox_test/main.tf @@ -42,7 +42,7 @@ resource "enos_local_exec" "run_blackbox_test" { # PATH and Go-related environment variables are inherited from the calling process }, var.vault_namespace != null ? { VAULT_NAMESPACE = var.vault_namespace - } : {}, local.ldap_environment, local.postgres_environment + } : {}, local.ldap_environment, local.postgres_environment, local.mongodb_environment ) depends_on = [local_file.test_matrix] } @@ -78,6 +78,15 @@ locals { PGPASSWORD = local.postgres_config.password PGDATABASE = local.postgres_config.database } : {} + + # Extract MongoDB configuration safely, defaulting to empty map if not available + mongodb_config = try(var.integration_host_state.mongodb, {}) + + # Set up MongoDB environment variables when MongoDB integration is available + mongodb_environment = try(local.mongodb_config.host.public_ip, "") != "" ? { + MONGO_URL = "mongodb://${local.mongodb_config.username}:${local.mongodb_config.password}@${local.mongodb_config.host.public_ip}:${local.mongodb_config.port}/${local.mongodb_config.database}?directConnection=true" + MONGO_URL_PRIVATE = "mongodb://${local.mongodb_config.username}:${local.mongodb_config.password}@${local.mongodb_config.host.private_ip}:${local.mongodb_config.port}/${local.mongodb_config.database}?directConnection=true" + } : {} } # Extract information from the script output 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 index 47a17dcb75..a90911d1ed 100644 --- 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 @@ -4,8 +4,10 @@ package mongodb import ( + "fmt" "testing" + "github.com/hashicorp/vault/api" "github.com/hashicorp/vault/sdk/helper/testcluster/blackbox" ) @@ -18,128 +20,33 @@ func TestMongoDBConnectionConfigCRUDWorkflows(t *testing.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) - }) + // TODO: Additional tests for future implementation: + // - ListReturnsNamesOnly: Verify listing returns only connection names + // - ReadRedactsSensitiveFields: Verify passwords are redacted in read operations + // - ResetConnection: Verify connection reset preserves configuration + // - DeleteConnection: Verify connection deletion and idempotency } -// 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) - }) -} +// TODO: TestMongoDBConnectionConfigValidationWorkflows +// Future implementation should test: +// - VerifyConnectionValid: Test with verify_connection=true and valid credentials +// - VerifyConnectionInvalid: Test with verify_connection=true and invalid credentials // 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") + requireVaultEnv(t) + cleanup, connURL := PrepareTestContainer(t) + defer cleanup() - // 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 + mount := fmt.Sprintf("database-%s", sanitize(t.Name())) + v.MustEnableSecretsEngine(mount, &api.MountInput{Type: "database"}) + + v.MustWrite( + mount+"/config/my-mongodb-db", + mongoConnectionConfigPayload(connURL, "*", false), + ) + + config := v.MustReadRequired(mount + "/config/my-mongodb-db") + v.AssertSecret(config).Data().HasKey("plugin_name", "mongodb-database-plugin") } diff --git a/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_static_roles_test.go b/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_static_roles_test.go new file mode 100644 index 0000000000..0a7519174c --- /dev/null +++ b/vault/external_tests/blackbox/plugins/database/mongodb/secret_mongodb_static_roles_test.go @@ -0,0 +1,355 @@ +// Copyright IBM Corp. 2025, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package mongodb + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/sdk/helper/testcluster/blackbox" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + testConnectionName = "my-mongodb-db" + testStaticRoleName = "my-static-role" + testUsername = "staticuser1" + testInitialPassword = "initialpass" + testRotationPeriod = 86400 // 24 hours in seconds + mongoConnectTimeout = 10 * time.Second +) + +// TestMongoDBStaticRoleWorkflows runs all MongoDB static role workflow tests. +func TestMongoDBStaticRoleWorkflows(t *testing.T) { + t.Run("CreateBasic", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBStaticRoleCreateBasic(t, v) + }) + + t.Run("ReadCredentials", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBStaticRoleReadCredentials(t, v) + }) + + t.Run("ManualRotation", func(t *testing.T) { + t.Parallel() + v := blackbox.New(t) + testMongoDBStaticRoleManualRotation(t, v) + }) +} + +// TODO: TestMongoDBStaticRoleValidationWorkflows +// Future implementation should test: +// - RequiresUsername: Verify error when username is missing +// - RequiresRotationPeriodOrSchedule: Verify error without rotation config +// - RejectsInvalidRotationPeriod: Verify error with period < 5 seconds +// - RejectsMutuallyExclusiveFields: Verify error with both period and schedule + +// testMongoDBStaticRoleCreateBasic verifies that creating a basic static role +// succeeds with required fields. +func testMongoDBStaticRoleCreateBasic(t *testing.T, v *blackbox.Session) { + mount, connURL := setupMongoDBTest(t, v) + + createMongoDBUser(t, connURL, testUsername, testInitialPassword) + + v.MustWrite(mount+"/static-roles/"+testStaticRoleName, map[string]any{ + "db_name": testConnectionName, + "username": testUsername, + "rotation_period": testRotationPeriod, + }) + + role := v.MustReadRequired(mount + "/static-roles/" + testStaticRoleName) + v.AssertSecret(role). + Data(). + HasKey("username", testUsername). + HasKey("rotation_period", float64(testRotationPeriod)). + HasKey("db_name", testConnectionName) +} + +// TODO: Additional test implementations for future teams +// - testMongoDBStaticRoleCreateWithRotationSchedule: Test rotation_schedule instead of rotation_period +// - testMongoDBStaticRoleListReturnsNamesOnly: Test listing multiple static roles +// - testMongoDBStaticRoleReadReturnsConfiguration: Test reading role config without sensitive data +// - testMongoDBStaticRoleUpdateRotationPeriod: Test updating rotation period +// - testMongoDBStaticRoleDeleteRole: Test deleting a static role + +// testMongoDBStaticRoleManualRotation verifies manually rotating a static +// role's credentials succeeds. +func testMongoDBStaticRoleManualRotation(t *testing.T, v *blackbox.Session) { + mount, connURL := setupMongoDBTest(t, v) + + createMongoDBUser(t, connURL, testUsername, testInitialPassword) + + v.MustWrite(mount+"/static-roles/"+testStaticRoleName, map[string]any{ + "db_name": testConnectionName, + "username": testUsername, + "rotation_period": testRotationPeriod, + }) + + creds1 := v.MustReadRequired(mount + "/static-creds/" + testStaticRoleName) + password1 := creds1.Data["password"].(string) + + if password1 == testInitialPassword { + t.Fatal("expected password to be rotated on role creation") + } + + v.MustWrite(mount+"/rotate-role/"+testStaticRoleName, nil) + + creds2 := v.MustReadRequired(mount + "/static-creds/" + testStaticRoleName) + password2 := creds2.Data["password"].(string) + + if password1 == password2 { + t.Fatal("expected password to change after rotation") + } + + verifyMongoDBCredentials(t, connURL, testUsername, password2) +} + +// TODO: testMongoDBStaticRoleAutomaticRotation - Test automatic rotation with short period +// This test requires waiting for rotation to occur, which may be time-consuming +// Pattern: Create role with short rotation_period, wait, verify password changed + +// testMongoDBStaticRoleReadCredentials verifies reading static credentials +// returns the current password. +func testMongoDBStaticRoleReadCredentials(t *testing.T, v *blackbox.Session) { + mount, connURL := setupMongoDBTest(t, v) + + createMongoDBUser(t, connURL, testUsername, testInitialPassword) + + v.MustWrite(mount+"/static-roles/"+testStaticRoleName, map[string]any{ + "db_name": testConnectionName, + "username": testUsername, + "rotation_period": testRotationPeriod, + }) + + creds := v.MustReadRequired(mount + "/static-creds/" + testStaticRoleName) + v.AssertSecret(creds). + Data(). + HasKey("username", testUsername). + HasKey("password", creds.Data["password"]) + + password := creds.Data["password"].(string) + if password == "" { + t.Fatal("expected non-empty password") + } + + verifyMongoDBCredentials(t, connURL, testUsername, password) +} + +// testMongoDBStaticRoleRequiresUsername verifies creating a static role +// without username fails. +func testMongoDBStaticRoleRequiresUsername(t *testing.T, v *blackbox.Session) { + mount, _ := setupMongoDBTest(t, v) + + _, err := v.Client.Logical().Write(mount+"/static-roles/"+testStaticRoleName, map[string]any{ + "db_name": testConnectionName, + "rotation_period": testRotationPeriod, + }) + + if err == nil { + t.Fatal("expected error when creating static role without username") + } + + if !strings.Contains(err.Error(), "username") { + t.Fatalf("expected error message to mention 'username', got: %v", err) + } +} + +// setupMongoDBTest performs common test setup: creates container, enables mount, configures connection. +// Returns mount path and connection URL. +func setupMongoDBTest(t *testing.T, v *blackbox.Session) (string, string) { + t.Helper() + + requireVaultEnv(t) + cleanup, connURL := PrepareTestContainer(t) + t.Cleanup(cleanup) + + mount := fmt.Sprintf("database-%s", sanitize(t.Name())) + v.MustEnableSecretsEngine(mount, &api.MountInput{Type: "database"}) + + v.MustWrite( + mount+"/config/"+testConnectionName, + mongoConnectionConfigPayload(connURL, "*", false), + ) + + return mount, connURL +} + +// createMongoDBUser creates a MongoDB user for testing static roles. +func createMongoDBUser(t *testing.T, connURL, username, password string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), mongoConnectTimeout) + defer cancel() + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(connURL)) + if err != nil { + t.Fatalf("failed to connect to MongoDB: %v", err) + } + defer client.Disconnect(ctx) + + db := client.Database("admin") + err = db.RunCommand(ctx, bson.D{ + {Key: "createUser", Value: username}, + {Key: "pwd", Value: password}, + {Key: "roles", Value: bson.A{ + bson.D{ + {Key: "role", Value: "readWrite"}, + {Key: "db", Value: "admin"}, + }, + }}, + }).Err() + if err != nil { + t.Fatalf("failed to create MongoDB user: %v", err) + } + + t.Logf("Created MongoDB user: %s", username) +} + +// verifyMongoDBCredentials verifies that the given credentials work for MongoDB. +func verifyMongoDBCredentials(t *testing.T, connURL, username, password string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), mongoConnectTimeout) + defer cancel() + + // Replace credentials in connection URL + u, err := parseMongoURL(connURL) + if err != nil { + t.Fatalf("failed to parse connection URL: %v", err) + } + + u.User = username + u.Password = password + testURL := buildMongoURL(u) + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(testURL)) + if err != nil { + t.Fatalf("failed to connect with credentials: %v", err) + } + defer client.Disconnect(ctx) + + if err := client.Ping(ctx, nil); err != nil { + t.Fatalf("failed to ping with credentials: %v", err) + } + + t.Logf("Verified MongoDB credentials for user: %s", username) +} + +// mongoURL represents a parsed MongoDB connection URL. +type mongoURL struct { + Scheme string + User string + Password string + Host string + Database string + Options string +} + +// parseMongoURL parses a MongoDB connection URL into components. +func parseMongoURL(connURL string) (*mongoURL, error) { + // Simple parser for mongodb:// URLs + // Format: mongodb://user:pass@host/database?options + u := &mongoURL{Scheme: "mongodb"} + + // Remove scheme + rest := connURL + if len(rest) > 10 && rest[:10] == "mongodb://" { + rest = rest[10:] + } + + // Extract user:pass if present + atIdx := -1 + for i, c := range rest { + if c == '@' { + atIdx = i + break + } + } + + if atIdx > 0 { + userPass := rest[:atIdx] + rest = rest[atIdx+1:] + + colonIdx := -1 + for i, c := range userPass { + if c == ':' { + colonIdx = i + break + } + } + + if colonIdx > 0 { + u.User = userPass[:colonIdx] + u.Password = userPass[colonIdx+1:] + } + } + + // Extract host and database + slashIdx := -1 + for i, c := range rest { + if c == '/' { + slashIdx = i + break + } + } + + if slashIdx > 0 { + u.Host = rest[:slashIdx] + rest = rest[slashIdx+1:] + + // Extract database and options + qIdx := -1 + for i, c := range rest { + if c == '?' { + qIdx = i + break + } + } + + if qIdx > 0 { + u.Database = rest[:qIdx] + u.Options = rest[qIdx+1:] + } else { + u.Database = rest + } + } else { + u.Host = rest + } + + return u, nil +} + +// buildMongoURL builds a MongoDB connection URL from components. +func buildMongoURL(u *mongoURL) string { + url := u.Scheme + "://" + + if u.User != "" { + url += u.User + if u.Password != "" { + url += ":" + u.Password + } + url += "@" + } + + url += u.Host + + if u.Database != "" { + url += "/" + u.Database + } + + if u.Options != "" { + url += "?" + u.Options + } + + return url +} 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 index b42bb23281..36d9660592 100644 --- 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 @@ -5,9 +5,12 @@ package mongodb import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "net/url" "os" + "regexp" "strings" "testing" "time" @@ -28,7 +31,7 @@ const ( // 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())), + ContainerName: fmt.Sprintf("mongo-%s", sanitize(t.Name())), ImageRepo: defaultMongoImage, ImageTag: defaultMongoVersion, Env: []string{ @@ -38,6 +41,7 @@ func defaultRunOpts(t *testing.T) docker.RunOptions { }, Ports: []string{"27017/tcp"}, DoNotAutoRemove: false, + PreDelete: true, OmitLogTimestamps: true, LogConsumer: func(s string) { if t.Failed() { @@ -56,28 +60,37 @@ func requireVaultEnv(t *testing.T) { } } -// sanitize converts test name to a valid container name -// Removes special characters and converts to lowercase +var sanitizeRegex = regexp.MustCompile(`[^a-z0-9]+`) + +// sanitize converts test name to a valid identifier with smart truncation +// Replaces non-alphanumeric characters with dashes and truncates long names +// with a hash suffix for uniqueness 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) - } + lower := strings.ToLower(name) + out := sanitizeRegex.ReplaceAllString(lower, "-") + out = strings.Trim(out, "-") + + if out == "" { + return "test" } - return result.String() + + // Truncate long names with hash suffix for uniqueness + if len(out) > 54 { + const hashLen = 8 + sum := sha256.Sum256([]byte(out)) + hash := hex.EncodeToString(sum[:])[:hashLen] + prefixLen := 54 - 1 - hashLen + out = out[:prefixLen] + "-" + hash + } + + return out } // 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) + _, cleanup, connURL, _ := prepareTestContainer(t, defaultRunOpts(t), defaultMongoPass, false, true) return cleanup, connURL } @@ -96,11 +109,17 @@ func prepareTestContainer( if os.Getenv("MONGO_URL") != "" { envMongoURL := os.Getenv("MONGO_URL") + // Use private URL for Vault, fall back to public + vaultMongoURL := os.Getenv("MONGO_URL_PRIVATE") + if vaultMongoURL == "" { + vaultMongoURL = envMongoURL + } + // Create unique database for this test dbName := fmt.Sprintf("test_%s_%d", sanitize(t.Name()), time.Now().Unix()) - testURL := replaceDatabase(envMongoURL, dbName) + testURL := replaceDatabase(vaultMongoURL, dbName) - // Create the database + // Create the database (test runner uses public URL) if err := createDatabase(t, envMongoURL, dbName); err != nil { t.Fatalf("Failed to create test database: %v", err) } @@ -115,25 +134,33 @@ func prepareTestContainer( // 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) + if strings.Contains(err.Error(), "Cannot connect to the Docker daemon") { + t.Fatalf("skipping blackbox test: docker daemon not available: %v", err) } t.Fatalf("Could not start docker MongoDB: %s", err) } - svc, containerID, err := runner.StartNewService( - context.Background(), - addSuffix, - forceLocalAddr, - connectMongoDB(password), - ) + // Retry StartNewService with small delays to handle port mapping timing + var svc *docker.Service + var containerID string + for attempt := 0; attempt < 5; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 500 * time.Millisecond) + } + + svc, containerID, err = runner.StartNewService(context.Background(), addSuffix, forceLocalAddr, connectMongoDB(password)) + if err == nil { + break + } + + if !strings.Contains(err.Error(), "no port mapping found") { + break + } + } + 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) + if strings.Contains(err.Error(), "Cannot connect to the Docker daemon") { + t.Fatalf("skipping blackbox test: docker daemon not available: %v", err) } t.Fatalf("Could not start docker MongoDB: %s", err) }