Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@
import lombok.extern.slf4j.Slf4j;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.images.builder.Transferable;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;

import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Enumeration;

/**
* Testcontainers implementation for MongoDB.
Expand Down Expand Up @@ -41,10 +47,16 @@ public class MongoDBContainer extends GenericContainer<MongoDBContainer> {

private static final String STARTER_SCRIPT = "/testcontainers_start.sh";

private static final String SCRIPT_DESTINATION_DEFAULT = "/docker-entrypoint-initdb.d/init.js";

private static final String SCRIPT_DESTINATION_MANUAL = "/tmp/init.js";

private boolean shardingEnabled;

private boolean rsEnabled;

private String initScriptPath;

public MongoDBContainer(@NonNull String dockerImageName) {
this(DockerImageName.parse(dockerImageName));
}
Expand All @@ -68,6 +80,69 @@ protected void containerIsStarted(InspectContainerResponse containerInfo, boolea
if (this.rsEnabled) {
initReplicaSet(reused);
}

boolean isClusterMode = this.shardingEnabled || this.rsEnabled;

if (isClusterMode && this.initScriptPath != null) {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In cluster mode (replica set/sharding), the init script is executed unconditionally in containerIsStarted(...), even when reused == true. This differs from the official entrypoint behavior (init scripts run only on first initialization) and can break non-idempotent scripts when container reuse is enabled. Consider skipping init-script execution on reuse, or documenting/controlling this behavior explicitly.

Suggested change
if (isClusterMode && this.initScriptPath != null) {
if (isClusterMode && !reused && this.initScriptPath != null) {

Copilot uses AI. Check for mistakes.
executeInitScriptInContainer();
}
}

/**
* Configures the container.
* <p>
* This method handles the transfer of the initialization script to the container.
* Unlike standard file copying mechanisms, this implementation explicitly reads the script content as bytes
* and uses {@link org.testcontainers.images.builder.Transferable} to copy it.
* <p>
* This approach is necessary to strictly support filenames containing special characters
* (e.g., "#", spaces, etc.) on the classpath. Standard resource loading methods may misinterpret
* these characters (e.g., treating "#" as a URL fragment), causing resolution failures.
* By manually resolving the file path and transferring the raw bytes, we ensure the script
* is correctly deployed regardless of its filename complexity.
*/
@Override
protected void configure() {
super.configure();
boolean isClusterMode = this.shardingEnabled || this.rsEnabled;
if (this.initScriptPath != null) {
try {
Path scriptPath = Paths.get(this.initScriptPath);
String fileName = scriptPath.getFileName().toString();
Path parentDir = scriptPath.getParent();
String resourceDir = (parentDir == null) ? "" : parentDir.toString();
Comment on lines +110 to +113
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure() derives resourceDir via Paths.get(initScriptPath) / parentDir.toString(), which uses platform-specific separators (e.g., \ on Windows). Classpath resource lookup expects / separators, so scripts located under nested directories can fail to resolve on Windows. Consider treating scriptPath as a classpath resource name (string with /) rather than a filesystem Path, or normalize resourceDir to / separators before calling getResources.

Suggested change
Path scriptPath = Paths.get(this.initScriptPath);
String fileName = scriptPath.getFileName().toString();
Path parentDir = scriptPath.getParent();
String resourceDir = (parentDir == null) ? "" : parentDir.toString();
// Treat initScriptPath as a classpath resource name, not a filesystem path
String normalizedScriptPath = this.initScriptPath.replace('\\', '/');
int lastSlash = normalizedScriptPath.lastIndexOf('/');
String fileName = (lastSlash == -1) ? normalizedScriptPath : normalizedScriptPath.substring(lastSlash + 1);
String resourceDir = (lastSlash == -1) ? "" : normalizedScriptPath.substring(0, lastSlash);

Copilot uses AI. Check for mistakes.

Enumeration<URL> resources = this.getClass().getClassLoader().getResources(resourceDir);
byte[] fileContent = null;

while (resources.hasMoreElements()) {
URL dirUrl = resources.nextElement();

if ("file".equals(dirUrl.getProtocol())) {
Path dirPath = Paths.get(dirUrl.toURI());
Path candidatePath = dirPath.resolve(fileName);

if (Files.exists(candidatePath) && !Files.isDirectory(candidatePath)) {
fileContent = Files.readAllBytes(candidatePath);
break;
}
}
Comment on lines +115 to +129
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The init script resolution only considers classpath resources with the file: protocol. If the init script is packaged in a JAR (common in some test setups/build tools), dirUrl.getProtocol() will be jar and the script will never be found, resulting in a runtime failure. Consider loading the resource bytes via ClassLoader.getResourceAsStream (or similar) so both file: and jar: classpath resources work.

Copilot uses AI. Check for mistakes.
}

if (fileContent == null) {
throw new RuntimeException("Could not find init script on classpath: " + this.initScriptPath);
}

String destination = isClusterMode ? SCRIPT_DESTINATION_MANUAL : SCRIPT_DESTINATION_DEFAULT;
withCopyToContainer(Transferable.of(fileContent, 0777), destination);
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The init script is copied into the container with mode 0777. For a .js init script, execute permissions are not required and 0777 is overly permissive; it’s safer to use a read-only mode like 0644 (or 0444) unless execution is needed.

Suggested change
withCopyToContainer(Transferable.of(fileContent, 0777), destination);
withCopyToContainer(Transferable.of(fileContent, 0644), destination);

Copilot uses AI. Check for mistakes.
} catch (Exception e) {
throw new RuntimeException("Failed to read or transfer init script: " + this.initScriptPath, e);
}
}

if (this.initScriptPath != null && !isClusterMode) {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure() unconditionally overwrites the container’s waitStrategy when initScriptPath is set in standalone mode. This prevents users from supplying a custom waitingFor(...) strategy (it will be silently replaced at startup). Consider only adjusting the wait strategy if it is still the default (GenericContainer.DEFAULT_WAIT_STRATEGY) or offering an opt-out.

Suggested change
if (this.initScriptPath != null && !isClusterMode) {
if (this.initScriptPath != null
&& !isClusterMode
&& (this.waitStrategy == null || this.waitStrategy == GenericContainer.DEFAULT_WAIT_STRATEGY)) {

Copilot uses AI. Check for mistakes.
this.waitStrategy = Wait.forLogMessage("(?i).*waiting for connections.*", 2);
}
}

private String[] buildMongoEvalCommand(String command) {
Expand Down Expand Up @@ -204,4 +279,46 @@ public String getReplicaSetUrl(String databaseName) {
}
return getConnectionString() + "/" + databaseName;
}

/**
* Executes a MongoDB initialization script from the classpath during startup.
* <p>
* In standalone mode, the script will be copied to {@code /docker-entrypoint-initdb.d/init.js},
* and the {@link org.testcontainers.containers.wait.strategy.WaitStrategy} is adjusted
* to expect the "waiting for connections" log message twice.
* <p>
* In Replica Set or Sharding mode, the script is copied to a temporary location and executed
* manually after the cluster is initialized.
*
* @param scriptPath the path to the init script file on the classpath
* @return this container instance
*/
public MongoDBContainer withInitScript(String scriptPath) {
this.initScriptPath = scriptPath;
return this;
}

@SneakyThrows
private void executeInitScriptInContainer() {
String cmd =
"mongosh " +
MONGODB_DATABASE_NAME_DEFAULT +
" " +
SCRIPT_DESTINATION_MANUAL +
" || mongo " +
MONGODB_DATABASE_NAME_DEFAULT +
" " +
SCRIPT_DESTINATION_MANUAL;

ExecResult result = execInContainer("sh", "-c", cmd);
if (result.getExitCode() != CONTAINER_EXIT_CODE_OK) {
throw new IllegalStateException(
String.format(
"Failed to execute init script.\nStdout: %s\nStderr: %s",
result.getStdout(),
result.getStderr()
)
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,72 @@ void shouldTestDatabaseName() {
assertThat(mongoDBContainer.getReplicaSetUrl(databaseName)).endsWith(databaseName);
}
}

@Test
void shouldExecuteInitScript() {
try (MongoDBContainer mongoDB = new MongoDBContainer("mongo:4.0.10").withInitScript("init.js")) {
mongoDB.start();
assertInitScriptExecuted(mongoDB);
}
}

@Test
void shouldExecuteInitScriptWithEdgeCases() {
try (
MongoDBContainer mongoDB = new MongoDBContainer("mongo:4.0.10").withInitScript("initEdgeCase!@#%^& *'().js")
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The init script resource name used here contains * ("initEdgeCase!@#%^& *'().js"). * is not a valid filename character on Windows filesystems, which can make the test resources impossible to check out/build on Windows and cause CI failures. Consider using a cross-platform-safe filename (still including tricky URL characters like # and spaces if needed) to validate the edge case.

Suggested change
MongoDBContainer mongoDB = new MongoDBContainer("mongo:4.0.10").withInitScript("initEdgeCase!@#%^& *'().js")
MongoDBContainer mongoDB = new MongoDBContainer("mongo:4.0.10").withInitScript("initEdgeCase!@#%^& '().js")

Copilot uses AI. Check for mistakes.
) {
mongoDB.start();

try (
com.mongodb.client.MongoClient client = com.mongodb.client.MongoClients.create(
mongoDB.getReplicaSetUrl()
)
) {
String expectedComplexName = "test_col_\"_with_specials_!@#%^&*()";
String expectedCollectionWithSpecialChars = "col with spaces & symbols !@#";

com.mongodb.client.MongoDatabase database = client.getDatabase("test");

assertThat(database.listCollectionNames())
.contains(expectedComplexName, expectedCollectionWithSpecialChars);

com.mongodb.client.MongoCollection<org.bson.Document> collection = database.getCollection(
expectedComplexName
);

org.bson.Document doc = collection.find(new org.bson.Document("_id", 1)).first();

assertThat(doc).isNotNull();

assertThat(doc.getString("key_with_quotes")).isEqualTo("This is a \"double quoted\" string");

assertThat(doc.getString("key_with_json_chars")).isEqualTo("{ } [ ] : ,");

assertThat(doc.getString("description"))
.isEqualTo("Insertion test for collection with special symbols");
}
}
}

@Test
void shouldExecuteInitScriptWithReplicaSet() {
try (MongoDBContainer mongo = new MongoDBContainer("mongo:7.0.0").withInitScript("init.js").withReplicaSet()) {
mongo.start();
assertInitScriptExecuted(mongo);
}
}

@Test
void shouldExecuteInitScriptWithSharding() {
try (MongoDBContainer mongo = new MongoDBContainer("mongo:7.0.0").withInitScript("init.js").withSharding()) {
mongo.start();
assertInitScriptExecuted(mongo);
}
}

private void assertInitScriptExecuted(MongoDBContainer mongo) {
try (com.mongodb.client.MongoClient client = com.mongodb.client.MongoClients.create(mongo.getReplicaSetUrl())) {
assertThat(client.getDatabase("test").listCollectionNames()).contains("test_collection");
}
}
}
1 change: 1 addition & 0 deletions modules/mongodb/src/test/resources/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
db.createCollection("test_collection");
16 changes: 16 additions & 0 deletions modules/mongodb/src/test/resources/initEdgeCase!@#%^& *'().js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
var complexCollectionName = 'test_col_"_with_specials_!@#%^&*()';

db.createCollection(complexCollectionName);

var collectionWithSpecialChars = "col with spaces & symbols !@#";

db.createCollection(collectionWithSpecialChars);

db.getCollection(complexCollectionName).insertOne({
"_id": 1,
"key_with_quotes": "This is a \"double quoted\" string",
"key_with_json_chars": "{ } [ ] : ,",
"description": "Insertion test for collection with special symbols"
});

print("Initialization completed: " + complexCollectionName);
Loading