Skip to content
207 changes: 106 additions & 101 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/modules/mongodb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@
"build": "tsc --project tsconfig.build.json"
},
"devDependencies": {
"mongoose": "^8.16.1"
"mongoose": "8.15.0"
},
"dependencies": {
"testcontainers": "^11.3.1"
"testcontainers": "^11.3.1",
"compare-versions": "^6.1.1"
}
}
24 changes: 24 additions & 0 deletions packages/modules/mongodb/src/mongodb-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,27 @@ describe("MongodbContainer", { timeout: 240_000 }, () => {
});
// }
});

describe("MongodbContainer connect with credentials", { timeout: 240_000 }, () => {
it.for([["mongo:4.0.1"], ["mongo:8.0"]])("should connect to %s with credentials", async ([image]) => {
const mongodbContainer = await new MongoDBContainer(image)
.withUsername("mongo_user")
.withPassword("mongo_password")
.start();
const connection = mongoose.createConnection(mongodbContainer.getConnectionString(), {
directConnection: true,
});
try {
const result = await connection.collection("testcontainers").insertOne({ title: "testcontainers" });
const id = result.insertedId.toString();
expect(id).not.toBeNull();
expect(id).not.toBe("");
const rsStatus = await connection.db?.admin().replSetGetStatus();
expect(rsStatus).toBeDefined();
expect(rsStatus?.set).toBe("rs0");
} finally {
await connection.close();
await mongodbContainer.stop();
}
});
});
97 changes: 57 additions & 40 deletions packages/modules/mongodb/src/mongodb-container.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,85 @@
import { AbstractStartedContainer, ExecResult, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
import { satisfies } from "compare-versions";
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";

const MONGODB_PORT = 27017;

export class MongoDBContainer extends GenericContainer {
private username = "";
private password = "";

constructor(image: string) {
super(image);
this.withExposedPorts(MONGODB_PORT)
.withCommand(["--replSet", "rs0"])
.withWaitStrategy(Wait.forLogMessage(/.*waiting for connections.*/i))
.withStartupTimeout(120_000);
}

public override async start(): Promise<StartedMongoDBContainer> {
return new StartedMongoDBContainer(await super.start());
this.withExposedPorts(MONGODB_PORT).withStartupTimeout(120_000);
}

protected override async containerStarted(startedTestContainer: StartedTestContainer): Promise<void> {
await this.initReplicaSet(startedTestContainer);
public withUsername(username: string): this {
if (username === "") throw new Error("Username should not be empty.");
this.username = username;
return this;
}

private async initReplicaSet(startedTestContainer: StartedTestContainer) {
await this.executeMongoEvalCommand(startedTestContainer, "rs.initiate();");
await this.executeMongoEvalCommand(startedTestContainer, this.buildMongoWaitCommand());
public withPassword(password: string): this {
if (password === "") throw new Error("Password should not be empty.");
this.password = password;
return this;
}

private async executeMongoEvalCommand(startedTestContainer: StartedTestContainer, command: string) {
const execResult = await startedTestContainer.exec(this.buildMongoEvalCommand(command));
this.checkMongoNodeExitCode(execResult);
public override async start(): Promise<StartedMongoDBContainer> {
const cmdArgs = ["--replSet", "rs0", "--bind_ip_all"];
this.withHealthCheck({
test: ["CMD-SHELL", this.buildMongoEvalCommand(this.initRsAndWait())],
interval: 250,
timeout: 60000,
retries: 1000,
}).withWaitStrategy(Wait.forHealthCheck());
if (this.username && this.password) {
cmdArgs.push("--keyFile", "/data/db/key.txt");
this.withEnvironment({
MONGO_INITDB_ROOT_USERNAME: this.username,
MONGO_INITDB_ROOT_PASSWORD: this.password,
})
.withCopyContentToContainer([
{
content: "1111111111",
mode: 0o400,
target: "/data/db/key.txt",
},
])
.withCommand(cmdArgs);
} else {
this.withCommand(cmdArgs);
}
return new StartedMongoDBContainer(await super.start(), this.username, this.password);
}

private buildMongoEvalCommand(command: string) {
return [this.getMongoCmdBasedOnImageTag(), "--eval", command];
const useMongosh = satisfies(this.imageName.tag, ">=5.0.0");
const args = [];
if (useMongosh) args.push("mongosh");
else args.push("mongo", "admin");
if (this.username && this.password) args.push("-u", this.username, "-p", this.password);
args.push("--host", "localhost", "--quiet", "--eval", command);
return args.join(" ");
}

private getMongoCmdBasedOnImageTag() {
return parseInt(this.imageName.tag[0]) >= 5 ? "mongosh" : "mongo";
}

private checkMongoNodeExitCode(execResult: ExecResult) {
const { exitCode, output } = execResult;
if (execResult.exitCode !== 0) {
throw new Error(`Error running mongo command. Exit code ${exitCode}: ${output}`);
}
}

private buildMongoWaitCommand() {
return `
var attempt = 0;
while(db.runCommand({isMaster: 1}).ismaster==false) {
if (attempt > 60) {
quit(1);
}
print(attempt); sleep(100); attempt++;
}
`;
private initRsAndWait() {
return `'try { rs.initiate(); } catch (e){} while (db.runCommand({isMaster: 1}).ismaster==false) { sleep(100); }'`;
}
}

export class StartedMongoDBContainer extends AbstractStartedContainer {
constructor(startedTestContainer: StartedTestContainer) {
private readonly username: string = "";
private readonly password: string = "";

constructor(startedTestContainer: StartedTestContainer, username: string, password: string) {
super(startedTestContainer);
this.username = username;
this.password = password;
}

public getConnectionString(): string {
if (this.username && this.password)
return `mongodb://${this.username}:${this.password}@${this.getHost()}:${this.getMappedPort(MONGODB_PORT)}?authSource=admin`;
return `mongodb://${this.getHost()}:${this.getMappedPort(MONGODB_PORT)}`;
}
}