Skip to content

Commit 3a4dfd7

Browse files
committed
MongoDB: support username/password credentials
Previously MongoDB did not support passing credentials, although the underlying image does support them. However, as the current container implementation always sets up replication, auth gets a bit more complicated. A keyfile neds to be generated and a wait needs to be added for auth setup. In future major version, it would make sense to drop the automatic replication from constructor and move it to option like withReplication Closes testcontainers#988
1 parent e4fa915 commit 3a4dfd7

File tree

3 files changed

+145
-4
lines changed

3 files changed

+145
-4
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as crypto from "crypto";
2+
import * as fs from "fs";
3+
import os from "os";
4+
import * as path from "path";
5+
6+
export default function createKeyfile(): string {
7+
const key = crypto.randomBytes(756).toString("base64"); // MongoDB requires at least 6 characters
8+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mongo-keyfile-"));
9+
const keyfilePath = path.join(tmpDir, "keyfile");
10+
11+
fs.writeFileSync(keyfilePath, key, { mode: 0o600 }); // Must be chmod 600
12+
13+
return keyfilePath;
14+
}

packages/modules/mongodb/src/mongodb-container.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,56 @@ describe("MongodbContainer", { timeout: 240_000 }, () => {
6161
await mongodbContainer.stop();
6262
});
6363
// }
64+
65+
it("uses custom credentials (Mongo4, old commands)", async () => {
66+
const mongodbContainer = await new MongoDBContainer("mongo:4.0.1")
67+
.withUsername("CustomUserName")
68+
.withPassword("CustomPassword")
69+
.start();
70+
71+
const db = mongoose.createConnection(mongodbContainer.getConnectionString(), { directConnection: true });
72+
73+
const fooCollection = db.collection("foo");
74+
const obj = { value: 1 };
75+
76+
const session = await db.startSession();
77+
await session.withTransaction(async () => {
78+
await fooCollection.insertOne(obj);
79+
});
80+
81+
expect(
82+
await fooCollection.findOne({
83+
value: 1,
84+
})
85+
).toEqual(obj);
86+
87+
await mongoose.disconnect();
88+
await mongodbContainer.stop();
89+
});
90+
91+
it("uses custom credentials (Mongo8, new commands)", async () => {
92+
const mongodbContainer = await new MongoDBContainer("mongo:8.0.8")
93+
.withUsername("CustomUserName")
94+
.withPassword("CustomPassword")
95+
.start();
96+
97+
const db = mongoose.createConnection(mongodbContainer.getConnectionString(), { directConnection: true });
98+
99+
const fooCollection = db.collection("foo");
100+
const obj = { value: 1 };
101+
102+
const session = await db.startSession();
103+
await session.withTransaction(async () => {
104+
await fooCollection.insertOne(obj);
105+
});
106+
107+
expect(
108+
await fooCollection.findOne({
109+
value: 1,
110+
})
111+
).toEqual(obj);
112+
113+
await mongoose.disconnect();
114+
await mongodbContainer.stop();
115+
});
64116
});

packages/modules/mongodb/src/mongodb-container.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { AbstractStartedContainer, ExecResult, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
2+
import createKeyfile from "./keyfile";
23

34
const MONGODB_PORT = 27017;
45

56
export class MongoDBContainer extends GenericContainer {
7+
private username: string | null = null;
8+
private password: string | null = null;
9+
private keyfilePath: string | null = null;
10+
611
constructor(image = "mongo:4.0.1") {
712
super(image);
813
this.withExposedPorts(MONGODB_PORT)
@@ -11,16 +16,46 @@ export class MongoDBContainer extends GenericContainer {
1116
.withStartupTimeout(120_000);
1217
}
1318

19+
public withUsername(username: string): this {
20+
this.username = username;
21+
return this;
22+
}
23+
24+
public withPassword(rootPassword: string): this {
25+
this.password = rootPassword;
26+
return this;
27+
}
28+
1429
public override async start(): Promise<StartedMongoDBContainer> {
15-
return new StartedMongoDBContainer(await super.start());
30+
if (this.username && this.password) {
31+
this.keyfilePath = createKeyfile();
32+
const containerKeyfilePath = "/etc/mongo-keyfile";
33+
this.withBindMounts([
34+
{
35+
source: this.keyfilePath,
36+
target: containerKeyfilePath,
37+
mode: "ro",
38+
},
39+
]);
40+
this.withCommand(["--replSet", "rs0", "--keyFile", containerKeyfilePath, "--bind_ip_all"]);
41+
this.withEnvironment({ MONGO_INITDB_ROOT_USERNAME: this.username, MONGO_INITDB_ROOT_PASSWORD: this.password });
42+
}
43+
44+
return new StartedMongoDBContainer(await super.start(), this.username, this.password);
1645
}
1746

1847
protected override async containerStarted(startedTestContainer: StartedTestContainer): Promise<void> {
48+
if (this.username && this.password) {
49+
await this.waitForRootUser(startedTestContainer);
50+
}
1951
await this.initReplicaSet(startedTestContainer);
2052
}
2153

2254
private async initReplicaSet(startedTestContainer: StartedTestContainer) {
23-
await this.executeMongoEvalCommand(startedTestContainer, "rs.initiate();");
55+
await this.executeMongoEvalCommand(
56+
startedTestContainer,
57+
"rs.initiate({ _id: 'rs0', members: [ { _id: 0, host: '127.0.0.1:27017' } ] });"
58+
);
2459
await this.executeMongoEvalCommand(startedTestContainer, this.buildMongoWaitCommand());
2560
}
2661

@@ -30,7 +65,25 @@ export class MongoDBContainer extends GenericContainer {
3065
}
3166

3267
private buildMongoEvalCommand(command: string) {
33-
return [this.getMongoCmdBasedOnImageTag(), "--eval", command];
68+
const cmd = [this.getMongoCmdBasedOnImageTag()];
69+
70+
if (this.username && this.password) {
71+
cmd.push(
72+
"admin",
73+
"--port",
74+
MONGODB_PORT.toString(),
75+
"-u",
76+
this.username,
77+
"-p",
78+
this.password,
79+
"--authenticationDatabase",
80+
"admin"
81+
);
82+
}
83+
84+
cmd.push("--eval", command);
85+
86+
return cmd;
3487
}
3588

3689
private getMongoCmdBasedOnImageTag() {
@@ -44,6 +97,20 @@ export class MongoDBContainer extends GenericContainer {
4497
}
4598
}
4699

100+
private async waitForRootUser(startedTestContainer: StartedTestContainer) {
101+
const checkCommand = this.buildMongoEvalCommand("db.runCommand({ connectionStatus: 1 })");
102+
103+
for (let i = 0; i < 30; i++) {
104+
const result = await startedTestContainer.exec(checkCommand);
105+
if (result.exitCode === 0 && result.output.includes(`${this.username}`)) {
106+
return;
107+
}
108+
await new Promise((res) => setTimeout(res, 1000));
109+
}
110+
111+
throw new Error("Root user not ready after 30s");
112+
}
113+
47114
private buildMongoWaitCommand() {
48115
return `
49116
var attempt = 0;
@@ -58,11 +125,19 @@ export class MongoDBContainer extends GenericContainer {
58125
}
59126

60127
export class StartedMongoDBContainer extends AbstractStartedContainer {
61-
constructor(startedTestContainer: StartedTestContainer) {
128+
constructor(
129+
startedTestContainer: StartedTestContainer,
130+
private readonly username: string | null,
131+
private readonly password: string | null
132+
) {
62133
super(startedTestContainer);
63134
}
64135

65136
public getConnectionString(): string {
137+
if (this.username !== null && this.password !== null) {
138+
return `mongodb://${this.username}:${this.password}@${this.getHost()}:${this.getMappedPort(MONGODB_PORT)}`;
139+
}
140+
66141
return `mongodb://${this.getHost()}:${this.getMappedPort(MONGODB_PORT)}`;
67142
}
68143
}

0 commit comments

Comments
 (0)