diff --git a/docs/modules/openLDAP.md b/docs/modules/openLDAP.md new file mode 100644 index 000000000..7df824eec --- /dev/null +++ b/docs/modules/openLDAP.md @@ -0,0 +1,31 @@ +# OpenLDAP Module + +[OpenLDAP](https://www.openldap.org/) is an open-source implementation of the LDAP protocol, enabling hierarchical storage and management of directory information. + +## Install + +```bash +npm install @testcontainers/openldap --save-dev +``` + +## Examples + + +[Start container:](../../packages/modules/openldap/src/openldap-container.test.ts) inside_block:startContainer + + + +[Connect openldap client to container:](../../packages/modules/openldap/src/openldap-container.test.ts) inside_block:simpleConnect + + + +[Start container with password authentication:](../../packages/modules/openldap/src/openldap-container.test.ts) inside_block:startWithCredentials + + + +[Define volume for persistent/predefined data:](../../packages/modules/openldap/src/openldap-container.test.ts) inside_block:persistentData + + + +[Execute a command inside the container:](../../packages/modules/openldap/src/openldap-container.test.ts) inside_block:executeCommand + diff --git a/mkdocs.yml b/mkdocs.yml index 7a6e8c4e4..e72f16c91 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -57,6 +57,7 @@ nav: - Neo4J: modules/neo4j.md - PostgreSQL: modules/postgresql.md - Qdrant: modules/qdrant.md + - OpenLDAP: modules/openLDAP.md - Redis: modules/redis.md - Selenium: modules/selenium.md - Weaviate: modules/weaviate.md diff --git a/package-lock.json b/package-lock.json index fab60db3a..3313665ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4861,6 +4861,10 @@ "resolved": "packages/modules/neo4j", "link": true }, + "node_modules/@testcontainers/openldap": { + "resolved": "packages/modules/openldap", + "link": true + }, "node_modules/@testcontainers/postgresql": { "resolved": "packages/modules/postgresql", "link": true @@ -4975,6 +4979,15 @@ "@types/readdir-glob": "*" } }, + "node_modules/@types/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/async-lock": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", @@ -5428,6 +5441,12 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -12161,6 +12180,36 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/ldapts": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-7.0.10.tgz", + "integrity": "sha512-B9X9xOfmLKO6lt579Yy5buQ5mJltKCRJ1tO89qXavH9k2cZpmNtvj/TH7I57eVEgj0t/gWyiqpdimN7lxU9MJw==", + "dev": true, + "dependencies": { + "@types/asn1": ">=0.2.4", + "@types/uuid": ">=9", + "asn1": "~0.2.6", + "debug": "~4.3.4", + "strict-event-emitter-types": "~2.0.0", + "uuid": "~9.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ldapts/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -16677,6 +16726,12 @@ "queue-tick": "^1.0.1" } }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -18547,6 +18602,17 @@ "neo4j-driver": "^5.17.0" } }, + "packages/modules/openldap": { + "name": "@testcontainers/openldap", + "version": "10.7.2", + "license": "MIT", + "dependencies": { + "testcontainers": "^10.7.2" + }, + "devDependencies": { + "ldapts": "^7.0.10" + } + }, "packages/modules/postgresql": { "name": "@testcontainers/postgresql", "version": "10.8.1", diff --git a/packages/modules/openldap/jest.config.ts b/packages/modules/openldap/jest.config.ts new file mode 100644 index 000000000..1f677baaf --- /dev/null +++ b/packages/modules/openldap/jest.config.ts @@ -0,0 +1,11 @@ +import type { Config } from "jest"; +import * as path from "path"; + +const config: Config = { + preset: "ts-jest", + moduleNameMapper: { + "^testcontainers$": path.resolve(__dirname, "../../testcontainers/src"), + }, +}; + +export default config; diff --git a/packages/modules/openldap/package.json b/packages/modules/openldap/package.json new file mode 100644 index 000000000..dc22788a1 --- /dev/null +++ b/packages/modules/openldap/package.json @@ -0,0 +1,37 @@ +{ + "name": "@testcontainers/openldap", + "version": "10.7.2", + "license": "MIT", + "keywords": [ + "openldap", + "testing", + "docker", + "testcontainers" + ], + "description": "openldap module for Testcontainers", + "homepage": "https://github.com/testcontainers/testcontainers-node#readme", + "repository": { + "type": "git", + "url": "https://github.com/testcontainers/testcontainers-node" + }, + "bugs": { + "url": "https://github.com/testcontainers/testcontainers-node/issues" + }, + "main": "build/index.js", + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "shx cp ../../../README.md . && shx cp ../../../LICENSE .", + "build": "tsc --project tsconfig.build.json" + }, + "devDependencies": { + "ldapts": "^7.0.10" + }, + "dependencies": { + "testcontainers": "^10.7.2" + } +} diff --git a/packages/modules/openldap/src/import.sh b/packages/modules/openldap/src/import.sh new file mode 100644 index 000000000..7d22a83b0 --- /dev/null +++ b/packages/modules/openldap/src/import.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e +# shellcheck disable=SC2046 +ldapadd -x $([[ -n "$1" ]] && echo "-D $1") $([[ -n "$2" ]] && echo "-w $2") -f /tmp/import.ldif +echo "Imported" \ No newline at end of file diff --git a/packages/modules/openldap/src/index.ts b/packages/modules/openldap/src/index.ts new file mode 100644 index 000000000..7386ca52d --- /dev/null +++ b/packages/modules/openldap/src/index.ts @@ -0,0 +1 @@ +export { OpenldapContainer, StartedOpenldapContainer } from "./openldap-container"; diff --git a/packages/modules/openldap/src/initData.ldif b/packages/modules/openldap/src/initData.ldif new file mode 100644 index 000000000..8f86c2a91 --- /dev/null +++ b/packages/modules/openldap/src/initData.ldif @@ -0,0 +1,27 @@ +# LDIF file to create two users + +# Entry for User 1 +dn: uid=testuser1,ou=users,dc=example,dc=com +changetype: add +objectClass: inetOrgPerson +objectClass: posixAccount +cn: User 1 +uid: user1 +sn: Lastname1 +userPassword: Password123 +uidNumber: 1001 +gidNumber: 1001 +homeDirectory: /home/user1 + +# Entry for User 2 +dn: uid=testuser2,ou=users,dc=example,dc=com +changetype: add +objectClass: inetOrgPerson +objectClass: posixAccount +cn: User 2 +uid: user2 +sn: Lastname2 +userPassword: Password456 +uidNumber: 1002 +gidNumber: 1002 +homeDirectory: /home/user2 diff --git a/packages/modules/openldap/src/openldap-container.test.ts b/packages/modules/openldap/src/openldap-container.test.ts new file mode 100644 index 000000000..c0076e497 --- /dev/null +++ b/packages/modules/openldap/src/openldap-container.test.ts @@ -0,0 +1,123 @@ +import { OpenldapContainer, StartedOpenldapContainer } from "./openldap-container"; +import * as os from "os"; +import * as path from "path"; +import * as fs from "fs"; +import { Client } from "ldapts"; + +describe("OpenLdapContainer", () => { + jest.setTimeout(240_000); + + // startContainer { + it("should connect and execute set-get", async () => { + const container = await new OpenldapContainer().withRootDn("dc=example,dc=org").start(); + + const client = await connectTo(container); + + const newUserName = "foo"; + const dn = `cn=${newUserName}, ${container.getRootDn()}`; + await client.add(dn, { + cn: newUserName, + uidNumber: "1000", + gidNumber: "1000", + homeDirectory: `/home/${newUserName}`, + uid: newUserName, + sn: "LastName", + objectclass: ["inetOrgPerson", "posixAccount"], + }); + const user = await client.search(dn); + expect(user.searchEntries[0].object).not.toBeNull(); + + await client.unbind(); + await container.stop(); + }); + // } + + it("should connect with password and execute set-get", async () => { + const container = await new OpenldapContainer().withPassword("test").start(); + + const client = await connectTo(container); + + //await client.set("key", "val"); + //expect(await client.get("key")).toBe("val"); + + await client.unbind(); + await container.stop(); + }); + + // persistentData { + it("should reconnect with volume and persistence data", async () => { + const sourcePath = fs.mkdtempSync(path.join(os.tmpdir(), "ldap-")); + const container = await new OpenldapContainer().withPassword("test").withPersistence(sourcePath).start(); + let client = await connectTo(container); + + //await client.set("key", "val"); + await client.unbind(); + await container.restart(); + client = await connectTo(container); + //expect(await client.get("key")).toBe("val"); + + await client.unbind(); + await container.stop(); + try { + fs.rmSync(sourcePath, { force: true, recursive: true }); + } catch (e) { + //Ignore clean up, when have no access on fs. + console.log(e); + } + }); + // } + + // initial data import { + it("should load initial data and can read it", async () => { + const container = await new OpenldapContainer() + .withPassword("test") + .withInitialLdif(path.join(__dirname, "initData.ldif")) + .start(); + const client = await connectTo(container); + const user1 = await client.search("uid=testuser1,ou=users,dc=example,dc=com"); + expect(user1.searchEntries.length).toBe(1); + + client.unbind(); + await container.stop(); + }); + // } + + // startWithCredentials { + it("should start with credentials and login", async () => { + const username = "cn=admin,dc=example,dc=org"; + const password = "testPassword"; + + // Test authentication + const container = await new OpenldapContainer().withUsername(username).withPassword(password).start(); + expect(container.getConnectionUrl()).toEqual(`ldap://${container.getHost()}:${container.getPort()}`); + expect(container.getUsername()).toEqual(username); + expect(container.getPassword()).toEqual(password); + const client = await connectTo(container); + + await client.unbind(); + await container.stop(); + }); + // } + + // executeCommand { + it("should execute container cmd and return the result", async () => { + const container = await new OpenldapContainer().start(); + + const queryResult = await container.executeCliCmd("info", ["clients"]); + expect(queryResult).toEqual(expect.stringContaining("connected_clients:1")); + + await container.stop(); + }); + // } + + // simpleConnect { + async function connectTo(container: StartedOpenldapContainer) { + const client = new Client({ + url: container.getConnectionUrl(), + }); + await client.bind(`cn=${container.getUsername()},${container.getRootDn()}`, container.getPassword()); + expect(client.isConnected).toBeTruthy(); + return client; + } + // } +}); diff --git a/packages/modules/openldap/src/openldap-container.ts b/packages/modules/openldap/src/openldap-container.ts new file mode 100644 index 000000000..9e3642dbd --- /dev/null +++ b/packages/modules/openldap/src/openldap-container.ts @@ -0,0 +1,144 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; +import path from "path"; + +const OPENLDAP_PORT = 1389; + +export class OpenldapContainer extends GenericContainer { + private readonly importFilePath = "/home/import.ldif"; + private username? = "admin"; + private password? = "adminpassword"; + private rootDn? = "dc=example,dc=org"; + private baseDn? = "cn=admin,dc=example,dc=org"; + private persistenceVolume? = ""; + private initialImportScriptFile? = ""; + + constructor(image = "bitnami/openldap:latest") { + super(image); + this.withEnvironment({ + LDAP_ADMIN_USERNAME: this.username ?? "", + LDAP_ADMIN_PASSWORD: this.password ?? "", + LDAP_ROOT: this.rootDn ?? "", + LDAP_BASE: this.baseDn ?? "", + }); + this.withExposedPorts(OPENLDAP_PORT) + .withStartupTimeout(120_000) + .withWaitStrategy(Wait.forAll([Wait.forLogMessage("slapd starting"), Wait.forListeningPorts()])); + } + + public withUsername(username: string): this { + this.username = username; + return this; + } + + public withPassword(password: string): this { + this.password = password; + return this; + } + + public withRootDn(rootDn: string): this { + this.rootDn = rootDn; + return this; + } + + public withBaseDn(baseDn: string): this { + this.baseDn = baseDn; + return this; + } + + public withPersistence(sourcePath: string): this { + this.persistenceVolume = sourcePath; + return this; + } + + public withInitialLdif(importScriptFile: string): this { + this.initialImportScriptFile = importScriptFile; + return this; + } + + public override async start(): Promise { + this.withEnvironment({ + LDAP_ADMIN_USERNAME: this.username ?? "", + LDAP_ADMIN_PASSWORD: this.password ?? "", + LDAP_ROOT: this.rootDn ?? "", + LDAP_BASE: this.baseDn ?? "", + }); + if (this.persistenceVolume) { + this.withBindMounts([{ mode: "rw", source: this.persistenceVolume, target: "/data" }]); + } + if (this.initialImportScriptFile) { + this.withCopyFilesToContainer([ + { + mode: 777, + source: this.initialImportScriptFile, + target: this.importFilePath, + }, + { + mode: 777, + source: path.join(__dirname, "import.sh"), + target: "/tmp/import.sh", + }, + ]); + } + const startedRedisContainer = new StartedOpenldapContainer( + await super.start(), + this.username, + this.password, + this.rootDn + ); + if (this.initialImportScriptFile) await this.importInitialData(startedRedisContainer); + return startedRedisContainer; + } + + private async importInitialData(container: StartedOpenldapContainer) { + const re = await container.exec(`/tmp/import.sh "${this.username},${this.rootDn}" ${this.password}`); + if (re.exitCode != 0 || re.output.includes("ERR")) + throw Error(`Could not import initial data from ${this.initialImportScriptFile}: ${re.output}`); + } +} + +export class StartedOpenldapContainer extends AbstractStartedContainer { + constructor( + startedTestContainer: StartedTestContainer, + private readonly username?: string, + private readonly password?: string, + private readonly rootDn?: string + ) { + super(startedTestContainer); + } + + public getPort(): number { + return this.getMappedPort(OPENLDAP_PORT); + } + + public getUsername(): string { + return this.username ?? ""; + } + + public getPassword(): string { + return this.password ?? ""; + } + + public getRootDn(): string { + return this.rootDn ?? ""; + } + + public getConnectionUrl(): string { + const url = new URL("", "ldap://"); + url.hostname = this.getHost(); + url.port = this.getPort().toString(); + return url.toString(); + } + + public async executeCliCmd(cmd: string, additionalFlags: string[] = []): Promise { + const result = await this.startedTestContainer.exec([ + "ldap-cli", + ...(this.password != "" ? [`-a ${this.password}`] : []), + `${cmd}`, + ...additionalFlags, + ]); + if (result.exitCode !== 0) { + throw new Error(`executeQuery failed with exit code ${result.exitCode} for query: ${cmd}. ${result.output}`); + } + return result.output; + } +} diff --git a/packages/modules/openldap/tsconfig.build.json b/packages/modules/openldap/tsconfig.build.json new file mode 100644 index 000000000..0222f6ff1 --- /dev/null +++ b/packages/modules/openldap/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "build", + "jest.config.ts", + "src/**/*.test.ts" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} \ No newline at end of file diff --git a/packages/modules/openldap/tsconfig.json b/packages/modules/openldap/tsconfig.json new file mode 100644 index 000000000..39b165817 --- /dev/null +++ b/packages/modules/openldap/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "paths": { + "testcontainers": [ + "../../testcontainers/src" + ] + } + }, + "exclude": [ + "build", + "jest.config.ts" + ], + "references": [ + { + "path": "../../testcontainers" + } + ] +} \ No newline at end of file