testcontainers_modules/openldap/
mod.rs

1use std::{borrow::Cow, collections::HashMap};
2
3use parse_display::{Display, FromStr};
4use testcontainers::{
5    core::{ContainerPort, WaitFor},
6    CopyDataSource, CopyToContainer, Image,
7};
8
9const NAME: &str = "bitnami/openldap";
10const TAG: &str = "2.6.8";
11const OPENLDAP_PORT: ContainerPort = ContainerPort::Tcp(1389);
12const OPENLDAPS_PORT: ContainerPort = ContainerPort::Tcp(1636);
13
14/// Module to work with [`OpenLDAP`] inside of tests.
15///
16/// Starts an instance of OpenLDAP.
17/// This module is based on the [`bitnami/openldap docker image`].
18/// See the [`OpenLDAP configuration guide`] for further configuration options.
19///
20/// # Example
21/// ```
22/// use testcontainers_modules::{openldap, testcontainers::runners::SyncRunner};
23///
24/// let openldap_instance = openldap::OpenLDAP::default().start().unwrap();
25/// let connection_string = format!(
26///     "ldap://{}:{}",
27///     openldap_instance.get_host().unwrap(),
28///     openldap_instance.get_host_port_ipv4(1389).unwrap(),
29/// );
30/// let mut conn = ldap3::LdapConn::new(&connection_string).unwrap();
31/// let ldap3::SearchResult(rs, _) = conn
32///     .search(
33///         "ou=users,dc=example,dc=org",
34///         ldap3::Scope::Subtree,
35///         "(cn=ma*)",
36///         vec!["cn"],
37///     )
38///     .unwrap();
39/// let results: Vec<_> = rs.into_iter().map(ldap3::SearchEntry::construct).collect();
40/// assert_eq!(results.len(), 0);
41/// ```
42///
43/// [`OpenLDAP`]: https://www.openldap.org/
44/// [`bitnami/openldap docker image`]: https://hub.docker.com/r/bitnami/openldap
45/// [`OpenLDAP configuration guide`]: https://www.openldap.org/doc/admin26/guide.html
46#[derive(Debug, Clone)]
47pub struct OpenLDAP {
48    env_vars: HashMap<String, String>,
49    users: Vec<User>,
50    copy_to_sources: Vec<CopyToContainer>,
51}
52#[derive(Debug, Clone)]
53struct User {
54    username: String,
55    password: String,
56}
57
58impl OpenLDAP {
59    /// Sets the LDAP baseDN (or suffix) of the LDAP tree.
60    /// Default: `"dc=example,dc=org"`
61    pub fn with_base_dn(mut self, base_dn: impl ToString) -> Self {
62        self.env_vars
63            .insert("LDAP_ROOT".to_owned(), base_dn.to_string());
64        self
65    }
66    /// Sets an admin account (there can only be one)
67    /// Default username: `"admin"` => dn: `cn=admin,dc=example,dc=org` if using the default `base_dn` instead of overriding via [`OpenLDAP::with_base_dn`].
68    /// Default password: `"adminpassword"`
69    pub fn with_admin(mut self, username: impl ToString, password: impl ToString) -> Self {
70        self.env_vars
71            .insert("LDAP_ADMIN_USERNAME".to_owned(), username.to_string());
72        self.env_vars
73            .insert("LDAP_ADMIN_PASSWORD".to_owned(), password.to_string());
74        self
75    }
76
77    /// Sets a configuration admin user (there can only be one)
78    /// Default: `None`
79    pub fn with_config_admin(mut self, username: impl ToString, password: impl ToString) -> Self {
80        self.env_vars
81            .insert("LDAP_CONFIG_ADMIN_ENABLED".to_owned(), "yes".to_owned());
82        self.env_vars.insert(
83            "LDAP_CONFIG_ADMIN_USERNAME".to_owned(),
84            username.to_string(),
85        );
86        self.env_vars.insert(
87            "LDAP_CONFIG_ADMIN_PASSWORD".to_owned(),
88            password.to_string(),
89        );
90        self
91    }
92
93    /// Sets an accesslog admin user up (there can only be one)
94    /// Configuring the admin for the access log can be done via [`OpenLDAP::with_accesslog_admin`]
95    /// Default: `None`
96    pub fn with_accesslog_settings(
97        mut self,
98        AccesslogSettings {
99            log_operations,
100            log_success,
101            log_purge,
102            log_old,
103            log_old_attribute,
104        }: AccesslogSettings,
105    ) -> Self {
106        self.env_vars
107            .insert("LDAP_ENABLE_ACCESSLOG".to_owned(), "yes".to_owned());
108        self.env_vars.insert(
109            "LDAP_ACCESSLOG_LOGOPS".to_owned(),
110            log_operations.to_string(),
111        );
112        if log_success {
113            self.env_vars
114                .insert("LDAP_ACCESSLOG_LOGSUCCESS".to_owned(), "TRUE".to_owned());
115        } else {
116            self.env_vars
117                .insert("LDAP_ACCESSLOG_LOGSUCCESS".to_owned(), "FALSE".to_owned());
118        }
119        self.env_vars.insert(
120            "LDAP_ACCESSLOG_LOGPURGE".to_owned(),
121            format!("{} {}", log_purge.0, log_purge.1),
122        );
123        self.env_vars
124            .insert("LDAP_ACCESSLOG_LOGOLD".to_owned(), log_old.to_string());
125        self.env_vars.insert(
126            "LDAP_ACCESSLOG_LOGOLDATTR".to_owned(),
127            log_old_attribute.to_string(),
128        );
129        self
130    }
131
132    /// Activates the access log and sets the admin user up (there can only be one)
133    /// Configuring how [`OpenLDAP`] logs can be done via [`OpenLDAP::with_accesslog_settings`]
134    /// Default: `None`
135    pub fn with_accesslog_admin(
136        mut self,
137        username: impl ToString,
138        password: impl ToString,
139    ) -> Self {
140        self.env_vars
141            .insert("LDAP_ENABLE_ACCESSLOG".to_owned(), "yes".to_owned());
142        self.env_vars.insert(
143            "LDAP_ACCESSLOG_ADMIN_USERNAME".to_owned(),
144            username.to_string(),
145        );
146        self.env_vars.insert(
147            "LDAP_ACCESSLOG_ADMIN_PASSWORD".to_owned(),
148            password.to_string(),
149        );
150        self
151    }
152
153    /// Adds a user (can be called multiple times)
154    /// Default: `[]`
155    ///
156    /// Alternatively, users can be added via [`OpenLDAP::with_users`].
157    pub fn with_user(mut self, username: impl ToString, password: impl ToString) -> Self {
158        self.users.push(User {
159            username: username.to_string(),
160            password: password.to_string(),
161        });
162        self
163    }
164
165    /// Add users (can be called multiple times)
166    /// Default: `[]`
167    ///
168    /// Alternatively, users can be added via [`OpenLDAP::with_user`].
169    pub fn with_users<Username: ToString, Password: ToString>(
170        mut self,
171        user_password: impl IntoIterator<Item = (Username, Password)>,
172    ) -> Self {
173        for (username, password) in user_password.into_iter() {
174            self.users.push(User {
175                username: username.to_string(),
176                password: password.to_string(),
177            })
178        }
179        self
180    }
181
182    /// Sets the users' dc
183    /// Default: `"users"`
184    pub fn with_users_dc(mut self, user_dc: impl ToString) -> Self {
185        self.env_vars
186            .insert("LDAP_USER_DC".to_owned(), user_dc.to_string());
187        self
188    }
189
190    /// Sets the users' group
191    /// Default: `"readers"`
192    pub fn with_users_group(mut self, users_group: impl ToString) -> Self {
193        self.env_vars
194            .insert("LDAP_GROUP".to_owned(), users_group.to_string());
195        self
196    }
197
198    /// Extra schemas to add, among [`OpenLDAP`]'s distributed schemas.
199    /// Default: `["cosine", "inetorgperson", "nis"]`
200    pub fn with_extra_schemas<S: ToString>(
201        mut self,
202        extra_schemas: impl IntoIterator<Item = S>,
203    ) -> Self {
204        self.env_vars
205            .insert("LDAP_ADD_SCHEMAS".to_owned(), "yes".to_owned());
206        let extra_schemas = extra_schemas
207            .into_iter()
208            .map(|s| s.to_string())
209            .collect::<Vec<String>>()
210            .join(", ");
211        self.env_vars
212            .insert("LDAP_EXTRA_SCHEMAS".to_owned(), extra_schemas);
213        self
214    }
215
216    /// Allow anonymous bindings to the LDAP server.
217    /// Default: `true`
218    pub fn with_allow_anon_binding(mut self, allow_anon_binding: bool) -> Self {
219        if allow_anon_binding {
220            self.env_vars
221                .insert("LDAP_ALLOW_ANON_BINDING".to_owned(), "yes".to_owned());
222        } else {
223            self.env_vars
224                .insert("LDAP_ALLOW_ANON_BINDING".to_owned(), "no".to_owned());
225        }
226        self
227    }
228
229    /// Set hash to be used in generation of user passwords.
230    /// Default: [`PasswordHash::SSHA`].
231    pub fn with_ldap_password_hash(mut self, password_hash: PasswordHash) -> Self {
232        self.env_vars
233            .insert("LDAP_PASSWORD_HASH".to_owned(), password_hash.to_string());
234        self
235    }
236
237    /// Sets a custom ldif file (content) which should be used.
238    /// Default: `[]`
239    pub fn with_ldif_file(mut self, source: impl Into<CopyDataSource>) -> Self {
240        let n = self.copy_to_sources.len() + 1;
241
242        let container_ldif_path = format!("/ldifs/custom{n}.ldif");
243
244        self.copy_to_sources
245            .push(CopyToContainer::new(source, container_ldif_path));
246        self
247    }
248
249    /// Set all necessary certificate artifacts to build up a secure communication.
250    /// Default: `[]`
251    pub fn with_tls(
252        mut self,
253        cert: impl Into<CopyDataSource>,
254        key: impl Into<CopyDataSource>,
255    ) -> Self {
256        self.copy_to_sources
257            .push(CopyToContainer::new(cert, "/certs/cert.crt"));
258        self.copy_to_sources
259            .push(CopyToContainer::new(key, "/certs/key.key"));
260
261        self.env_vars.insert(
262            "LDAP_TLS_CERT_FILE".to_owned(),
263            "/certs/cert.crt".to_owned(),
264        );
265        self.env_vars
266            .insert("LDAP_TLS_KEY_FILE".to_owned(), "/certs/key.key".to_owned());
267
268        self.env_vars
269            .insert("LDAP_ENABLE_TLS".to_owned(), "yes".to_owned());
270
271        self
272    }
273
274    /// Sets the root certificate used for signing the tls certificate.
275    /// Default: `[]`
276    pub fn with_cert_ca(mut self, ca: impl Into<CopyDataSource>) -> Self {
277        self.copy_to_sources
278            .push(CopyToContainer::new(ca, "/certs/ca.crt"));
279        self.env_vars
280            .insert("LDAP_TLS_CA_FILE".to_owned(), "/certs/ca.crt".to_owned());
281        self
282    }
283}
284
285/// hash to be used in generation of user passwords.
286#[derive(Display, FromStr, Default, Debug, Clone, Copy, Eq, PartialEq)]
287#[display("{{{}}}")] // it's escaped curly braces => `SSHA` gets displayed as `{SSHA}`
288pub enum PasswordHash {
289    #[default]
290    /// [`PasswordHash::SHA`], but with a salt.
291    /// It is believed to be the **most secure password storage scheme supported by slapd**.
292    SSHA,
293    /// Like the MD5 scheme, this simply feeds the password through an SHA hash process.
294    ///
295    /// <div class="warning">SHA is thought to be more secure than MD5, but the lack of salt leaves the scheme exposed to dictionary attacks.</div>
296    SHA,
297    /// [`PasswordHash::MD5`], but with a salt.
298    /// Salt = Random data which means that there are many possible representations of a given plaintext password
299    SMD5,
300    /// Simply takes the MD5 hash of the password and stores it in base64 encoded form.
301    /// <div class="warning">MD5 algorithm is fast, and because there is no salt the scheme is vulnerable to a dictionary attack</div>
302    MD5,
303    /// Uses the operating system's `crypt(3)` hash function.
304    /// It normally produces the traditional Unix-style 13 character hash, but on systems with `glibc2` it can also generate the more secure 34-byte `MD5` hash.
305    ///
306    /// <div class="warning">
307    ///
308    /// This scheme uses the operating system's `crypt(3)` hash function.
309    /// It is therefore operating system specific.
310    ///
311    /// </div>
312    CRYPT,
313    /// stored as-is
314    CLEARTEXT,
315}
316
317/// Specifies which types of operations to log
318#[derive(Display, FromStr, Default, Debug, Clone, Copy, Eq, PartialEq)]
319#[display(style = "lowercase")]
320pub enum AccesslogLogOperations {
321    /// Logs only writes
322    #[default]
323    Writes,
324    /// Logs only reads
325    Reads,
326    /// Logs only sessions
327    Session,
328    /// Logs everything
329    All,
330}
331
332/// allows finer grained control of the access logging
333#[derive(Debug, Clone, Eq, PartialEq)]
334pub struct AccesslogSettings {
335    log_operations: AccesslogLogOperations,
336    log_success: bool,
337    log_purge: (String, String),
338    log_old: String,
339    log_old_attribute: String,
340}
341impl AccesslogSettings {
342    /// Specify which types of operations to log.
343    /// Default: [`AccesslogLogOperations::Writes`]
344    pub fn with_log_operations(mut self, log_operations: AccesslogLogOperations) -> Self {
345        self.log_operations = log_operations;
346        self
347    }
348    /// Whether successful operations should be logged
349    /// Default: `true`
350    pub fn with_log_success(mut self, log_success: bool) -> Self {
351        self.log_success = log_success;
352        self
353    }
354    /// When and how often old access log entries should be purged. Format "dd+hh:mm".
355    /// Default: `("07+00:00", "01+00:00")`.
356    pub fn with_log_purge(
357        mut self,
358        (when_log_purge, how_often_log_purge): (impl ToString, impl ToString),
359    ) -> Self {
360        self.log_purge = (when_log_purge.to_string(), how_often_log_purge.to_string());
361        self
362    }
363    /// An LDAP filter that determines which entries should be logged.
364    /// Default: `"(objectClass=*)"`
365    pub fn with_log_old(mut self, log_old: impl ToString) -> Self {
366        self.log_old = log_old.to_string();
367        self
368    }
369    /// Specifies an attribute that should be logged.
370    /// Default: `"objectClass"`.
371    pub fn with_log_old_attribute(mut self, log_old_attribute: impl ToString) -> Self {
372        self.log_old_attribute = log_old_attribute.to_string();
373        self
374    }
375}
376
377impl Default for AccesslogSettings {
378    fn default() -> Self {
379        Self {
380            log_operations: AccesslogLogOperations::Writes,
381            log_success: true,
382            log_purge: ("07+00:00".to_owned(), "01+00:00".to_owned()),
383            log_old: "(objectClass=*)".to_owned(),
384            log_old_attribute: "objectClass".to_owned(),
385        }
386    }
387}
388
389impl Default for OpenLDAP {
390    /// Starts an instance with horrible passwords values.
391    /// Obviously not to be emulated in production.
392    ///
393    /// Defaults to:
394    /// - Admin: (username: `"admin"`, password: `"adminpassword"`, dn: `"cn=admin,dc=example,dc=org"`)
395    /// - Users: `[]`
396    /// - Accesslog admin: `None`
397    /// - Anonymous bindings: `true`
398    fn default() -> Self {
399        Self {
400            users: vec![],
401            env_vars: HashMap::new(),
402            copy_to_sources: vec![],
403        }
404    }
405}
406
407impl Image for OpenLDAP {
408    fn name(&self) -> &str {
409        NAME
410    }
411
412    fn tag(&self) -> &str {
413        TAG
414    }
415
416    fn ready_conditions(&self) -> Vec<WaitFor> {
417        // maybe OpenLDAP will have a healthcheck someday
418        // https://github.com/osixia/docker-openldap/issues/637
419        vec![
420            WaitFor::message_on_stderr("** Starting slapd **"),
421            WaitFor::seconds(2), // ideally, one should wait for a port instead of waiting a fixed time
422        ]
423    }
424
425    fn env_vars(
426        &self,
427    ) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
428        let mut vars = self.env_vars.clone();
429        let users = self
430            .users
431            .clone()
432            .into_iter()
433            .map(|u| u.username)
434            .collect::<Vec<String>>();
435        vars.insert("LDAP_USERS".to_owned(), users.join(", "));
436        let passwords = self
437            .users
438            .clone()
439            .into_iter()
440            .map(|u| u.password)
441            .collect::<Vec<String>>();
442        vars.insert("LDAP_PASSWORDS".to_owned(), passwords.join(", "));
443        vars
444    }
445
446    fn expose_ports(&self) -> &[ContainerPort] {
447        &[OPENLDAP_PORT, OPENLDAPS_PORT]
448    }
449
450    fn copy_to_sources(&self) -> impl IntoIterator<Item = &CopyToContainer> {
451        self.copy_to_sources
452            .iter()
453            .collect::<Vec<&CopyToContainer>>()
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use ldap3::{Ldap, LdapConnAsync, LdapError, Scope, SearchEntry};
460    use testcontainers::runners::AsyncRunner;
461
462    use super::*;
463
464    async fn read_users(
465        ldap: &mut Ldap,
466        filter: &str,
467        attrs: &[&str],
468    ) -> Result<Vec<SearchEntry>, LdapError> {
469        let (rs, _res) = ldap
470            .search("ou=users,dc=example,dc=org", Scope::Subtree, filter, attrs)
471            .await?
472            .success()?;
473        let res = rs.into_iter().map(SearchEntry::construct).collect();
474        Ok(res)
475    }
476
477    async fn read_access_log(
478        ldap: &mut Ldap,
479        filter: &str,
480        attrs: &[&str],
481    ) -> Result<Vec<SearchEntry>, LdapError> {
482        let (rs, _res) = ldap
483            .search("cn=accesslog", Scope::Subtree, filter, attrs)
484            .await?
485            .success()?;
486        let res = rs.into_iter().map(SearchEntry::construct).collect();
487        Ok(res)
488    }
489
490    #[tokio::test]
491    async fn ldap_users_noauth() -> Result<(), Box<dyn std::error::Error + 'static>> {
492        let _ = pretty_env_logger::try_init();
493        let openldap_image = OpenLDAP::default()
494            .with_allow_anon_binding(true)
495            .with_user("maximiliane", "pwd1")
496            .with_user("maximus", "pwd2")
497            .with_user("lea", "pwd3");
498        let node = openldap_image.start().await?;
499
500        let connection_string = format!(
501            "ldap://{}:{}",
502            node.get_host().await?,
503            node.get_host_port_ipv4(OPENLDAP_PORT).await?,
504        );
505        let (conn, mut ldap) = LdapConnAsync::new(&connection_string).await?;
506        ldap3::drive!(conn);
507        let users = read_users(&mut ldap, "(cn=ma*)", &["cn"]).await?;
508        let users: HashMap<String, _> = users.into_iter().map(|u| (u.dn, u.attrs)).collect();
509        let expected_result_maximiliane = (
510            "cn=maximiliane,ou=users,dc=example,dc=org".to_string(),
511            HashMap::from([(
512                "cn".to_string(),
513                vec!["User1".to_string(), "maximiliane".to_string()],
514            )]),
515        );
516        let expected_result_maximus = (
517            "cn=maximus,ou=users,dc=example,dc=org".to_string(),
518            HashMap::from([(
519                "cn".to_string(),
520                vec!["User2".to_string(), "maximus".to_string()],
521            )]),
522        );
523        assert_eq!(
524            users,
525            HashMap::from([expected_result_maximus, expected_result_maximiliane])
526        );
527        ldap.unbind().await?;
528        Ok(())
529    }
530
531    #[tokio::test]
532    async fn ldap_users_simple_bind() -> Result<(), Box<dyn std::error::Error + 'static>> {
533        let _ = pretty_env_logger::try_init();
534        let openldap_image = OpenLDAP::default()
535            .with_allow_anon_binding(false)
536            .with_user("maximiliane", "pwd1");
537        let node = openldap_image.start().await?;
538
539        let connection_string = format!(
540            "ldap://{}:{}",
541            node.get_host().await?,
542            node.get_host_port_ipv4(OPENLDAP_PORT).await?,
543        );
544        let (conn, mut ldap) = LdapConnAsync::new(&connection_string).await?;
545        ldap3::drive!(conn);
546        ldap.simple_bind("cn=maximiliane,ou=users,dc=example,dc=org", "pwd1")
547            .await?
548            .success()?;
549        let users = read_users(&mut ldap, "(cn=*)", &["cn"]).await?;
550        assert_eq!(users.len(), 2); // cn=maximiliane and cn=readers
551        ldap.unbind().await?;
552        Ok(())
553    }
554
555    #[tokio::test]
556    async fn ldap_access_logs_noauth() -> Result<(), Box<dyn std::error::Error + 'static>> {
557        let _ = pretty_env_logger::try_init();
558        let openldap_image = OpenLDAP::default()
559            .with_allow_anon_binding(true)
560            .with_accesslog_settings(
561                AccesslogSettings::default().with_log_operations(AccesslogLogOperations::Reads),
562            );
563        let node = openldap_image.start().await?;
564
565        let connection_string = format!(
566            "ldap://{}:{}",
567            node.get_host().await?,
568            node.get_host_port_ipv4(OPENLDAP_PORT).await?,
569        );
570        let (conn, mut ldap) = LdapConnAsync::new(&connection_string).await?;
571        ldap3::drive!(conn);
572
573        let access = read_access_log(&mut ldap, "(reqType=search)", &["*"]).await?;
574        assert_eq!(access.len(), 0, "no search until now");
575
576        let users = read_users(&mut ldap, "(cn=*)", &["cn"]).await?;
577        assert_eq!(users.len(), 3, "cn=readers should be read");
578
579        let access = read_access_log(&mut ldap, "(reqType=search)", &["*"]).await?;
580        assert_eq!(access.len(), 1, "access log contains 1xread_users");
581
582        ldap.unbind().await?;
583        Ok(())
584    }
585
586    #[tokio::test]
587    async fn ldap_with_ldif() -> Result<(), Box<dyn std::error::Error + 'static>> {
588        let _ = pretty_env_logger::try_init();
589        let ldif = r#"
590version: 1
591
592# Entry 1: dc=example,dc=org
593dn: dc=example,dc=org
594objectClass: top
595objectClass: domain
596dc: example
597
598# Entry 2: ou=users,dc=frauscher,dc=test
599dn: ou=users,dc=example,dc=org
600objectclass: organizationalUnit
601objectclass: top
602ou: users
603
604# Entry 3: cn=maximiliane,dc=example,dc=org
605dn: cn=maximiliane,ou=users,dc=example,dc=org
606cn: maximiliane
607gidnumber: 501
608givenname: Florian
609homedirectory: /home/users/maximiliane
610objectclass: inetOrgPerson
611objectclass: posixAccount
612objectclass: top
613sn: Maximiliane
614uid: maximiliane
615uidnumber: 1001
616userpassword: {SSHA}1vtVpqX6Y77jAVs/1uTd/rHS8YRYEh/9
617"#;
618
619        let openldap_image = OpenLDAP::default().with_ldif_file(ldif.to_string().into_bytes());
620
621        let node = openldap_image.start().await?;
622
623        let connection_string = format!(
624            "ldap://{}:{}",
625            node.get_host().await?,
626            node.get_host_port_ipv4(OPENLDAP_PORT).await?,
627        );
628        let (conn, mut ldap) = LdapConnAsync::new(&connection_string).await?;
629        ldap3::drive!(conn);
630        ldap.simple_bind("cn=maximiliane,ou=users,dc=example,dc=org", "pwd1")
631            .await?
632            .success()?;
633        let users = read_users(&mut ldap, "(cn=*)", &["cn"]).await?;
634        assert_eq!(users.len(), 1); // cn=maximiliane
635        ldap.unbind().await?;
636        Ok(())
637    }
638
639    #[tokio::test]
640    async fn ldap_secure() -> Result<(), Box<dyn std::error::Error + 'static>> {
641        let _ = pretty_env_logger::try_init();
642
643        let root_ca = r#"-----BEGIN CERTIFICATE-----
644MIIDazCCAlOgAwIBAgIUacPFMoNlemFtn974hn0x4c+ssGowDQYJKoZIhvcNAQEL
645BQAwRTELMAkGA1UEBhMCQVQxDTALBgNVBAoMBFRlc3QxFjAUBgNVBAsMDVRlc3Rj
646b250YWluZXIxDzANBgNVBAMMBnJvb3RDQTAeFw0yNDA5MjUxODI0MDFaFw00NDA5
647MjAxODI0MDFaMEUxCzAJBgNVBAYTAkFUMQ0wCwYDVQQKDARUZXN0MRYwFAYDVQQL
648DA1UZXN0Y29udGFpbmVyMQ8wDQYDVQQDDAZyb290Q0EwggEiMA0GCSqGSIb3DQEB
649AQUAA4IBDwAwggEKAoIBAQC4+4uW9PE5HQGWtn4/P1wPm11z/UevNGveDEUvwcDz
650oEZlfRLTihoVfc7EZGUF8/6TS2rkp1qz2yrDDP4o7a7l0Om22vCshsWlhhqQ1mON
651tiXnOWXqKjon6NZatvYnQVvUwm8BuSi4HR4xNM8N9BF81rhFj3tbam3lylJign/3
652sFsxUg2C3mAaDoolTFslBPPnKHW2EmSw04kgC6XhWGXqpAbWbL5h2rlOE/WoLTkl
653f+EpILsCyEApNbVLXh+B9+IrJvNqX6yuFL7zzJhefOa1L1sAG0bNc9nAwuwyDcDu
654OzxULkv0JGK2Dob6NL74NSB96kPLbZi/FRIHwhBgmUlPAgMBAAGjUzBRMB0GA1Ud
655DgQWBBQ2Bcju3oNHso17GQFoKd5/46OsnjAfBgNVHSMEGDAWgBQ2Bcju3oNHso17
656GQFoKd5/46OsnjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAP
657za/O0NzyLZjjOoBsDKyJzvgHv/Ls8yBRSmwAbJoXqT1F4F39px5ZVOdmruXeJsG1
658PIaLb2/4oPoSgqLfiPUMAJelTjD/CVpfFthiorCgYPSIIhkjgc6jdhCez67gotFE
659nBITxIRg47yAmCA+0+/YtTnul1deB9r2cuXeHVTPfUVphDsKSGVLJS0TK1iIL/PZ
660k/bSZzdk/i80spmFS3W/fLHPWFUbio1r0CBpbibQNl19x8uHz+J7L1kmW7gofyd9
661w4a3nYIMqapx5KGgGqI3lyc/ePet2JhUabbu7rQx6oEHJaaco3qip2t6pS0WtDcG
662B5tEQNlAfsx/Rfi4JUNi
663-----END CERTIFICATE-----
664"#;
665        let key = r#"-----BEGIN PRIVATE KEY-----
666MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDB8KR+QtENXXI0
667+BWZ/rD2nDfRi7v6dDLpD1yuVLFbBgVMDisf8CoqZP67x9lW5sRz/CXLjY+If1Vi
668xFxcwURzVBWmrMBLbEgDnsyX0GhgCdZTJM3eBTXwCN77bH/xSkZ6Dg1AV/i/ltAI
669TvMlE5Azrd/0IePWEQYtQYUQMlJHcxrDex3sKZ/oM6ohMl1sbkNDV+Ncatl7bIel
670RDyHE1SgExFFL2qWg2CeHJHPliq/0osp1cU99SCtaalxwisnih0GMswIf++1s+r6
671/CGRI+5xXOd1wtPwcjFxdVnSuhbFxniz9fPaPXw0NoVBksRcJNri2RHDZpW7nK+D
672OMcvWtxxAgMBAAECggEANQPOQ4GKWgfwX1Btv0HjKBa+H3b+NNGs1Q7Q/ArEzKgR
673rJ+25C0nqZ0gET7pR5sfmsETp9gTo3GDatNYmDZwusIChSR2EGgSK4MuVFWxIoet
6744d6OtCFihDI4miwnsVLnfxf2QV+K7PyR86N5TepSIf5m2PqmqG7Q7HAbqrjGyybO
675te4IIYUox2OirICfNMdy6ROj3c+oEiXu5eEpxbl01WzSU7rO4fMdfc0O/cocg2rr
676njBXD0utCiUw0M6EaisYzAg5ughP02enmxGzGwBT+lr2AQHG3Mk++PICRxsiBIv0
677T0o6Q3mIR4YMNDt7dZVz8mjL7lx1iuczM5CUbRnhzQKBgQDxTubdOmTidd1WxRJ5
6784t19Ga604YVG8c97hZ7fpNL0fTY/JcxkEN02uhHgA9fmBhE2vRus82x+iLKjcNBY
679ZKzM2PaRsjK2g9UNi829Zii/VjZJX1QwipO0EQt0Rruf+QtWXL47UHKTxpRfLrFN
680zaXrVM15uq/pdhmovj+LQ4NplwKBgQDNv3JQVXBImy9/5h2xPwVlkjXzQdD0PTEs
681emnLRfnctyqAzmtgTsbS1tDhoCwm5TuI6MMbrtnojSQKRr68BDfhvnm4TBHWYrS1
6820BqY+P1XkJH4jmIQsRpS/ZnDlB50+aiAnuMahui0mUT71/upGS277Zqr+u7Gkt2b
683fFeWzu3bNwKBgFiU+FbZ6tLfJaOGsKOhzmDwHpwz9XL3rYzQnmPG49HwbQt9WqyZ
684LDu8zncHsie0rnkDrrcsnPVORRWOgk0QmAaS1uDhI5CwkHNqkNooOGkUwtToc8Vl
685+Zaucx/6H0I4cBsB7KtlesoYqbrPLzM6fOAIv20iRRVUz1KMlFMRM5p9AoGBAJJr
686Tv/Kfbi974S2j6Tms4GAFrLBwOE/dvIvP4CwkMs48p9txs5n4WiEBWy73w/jDIY3
687FzppKZwsbVx+0hfdbKNTOS4lvH/0CKRmr7bzYt9g+/CF61Xzo0cyQK4Fh9M5JGg8
688KmRjY9G6TXRoVSkWyQw3YF5JmoloVRrk1zR0mKLrAoGBAKPJmagtBVmTvKdYFhs0
689X6au0mG5xN4HnkdvX1sGgeg+DgJ/dCa4LA3lZagUhDjupR0QgaaYVZhyFe6lDVck
690lOB14QZNoFr+xwiSDg4oM4/TQXualO/4nZxzUynbZNdNuvhQ8mRdeu5jqs363deP
691bCWwJaA6QQpNSitVrzg5XbRQ
692-----END PRIVATE KEY-----
693"#;
694        let cert = r#"-----BEGIN CERTIFICATE-----
695MIIDqzCCApOgAwIBAgIUJArHsm2jdPlyOXWC7TWdFxYD19kwDQYJKoZIhvcNAQEL
696BQAwRTELMAkGA1UEBhMCQVQxDTALBgNVBAoMBFRlc3QxFjAUBgNVBAsMDVRlc3Rj
697b250YWluZXIxDzANBgNVBAMMBnJvb3RDQTAeFw0yNDA5MjUxODI0MDJaFw00NDA5
698MjAxODI0MDJaME8xCzAJBgNVBAYTAkFUMQ0wCwYDVQQKDARUZXN0MRYwFAYDVQQL
699DA1UZXN0Y29udGFpbmVyMRkwFwYDVQQDDBBsZGFwLmV4YW1wbGUub3JnMIIBIjAN
700BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwfCkfkLRDV1yNPgVmf6w9pw30Yu7
701+nQy6Q9crlSxWwYFTA4rH/AqKmT+u8fZVubEc/wly42PiH9VYsRcXMFEc1QVpqzA
702S2xIA57Ml9BoYAnWUyTN3gU18Aje+2x/8UpGeg4NQFf4v5bQCE7zJROQM63f9CHj
7031hEGLUGFEDJSR3Maw3sd7Cmf6DOqITJdbG5DQ1fjXGrZe2yHpUQ8hxNUoBMRRS9q
704loNgnhyRz5Yqv9KLKdXFPfUgrWmpccIrJ4odBjLMCH/vtbPq+vwhkSPucVzndcLT
7058HIxcXVZ0roWxcZ4s/Xz2j18NDaFQZLEXCTa4tkRw2aVu5yvgzjHL1rccQIDAQAB
706o4GIMIGFMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAeBgNV
707HREEFzAVgglsb2NhbGhvc3SCCG9wZW5sZGFwMB0GA1UdDgQWBBTOko7NFMj7/fd/
708KGvfbYezhjmH8zAfBgNVHSMEGDAWgBQ2Bcju3oNHso17GQFoKd5/46OsnjANBgkq
709hkiG9w0BAQsFAAOCAQEAlud2ST43+Q2Sa+VHS8Tio1M76+AdNj1dmQHYsFN7Vm91
710cAEOFOO8y/oSqTZrIuxenFCIsMeAAVOEZ7BjcpzX50ncHAYDu2szpmTscvujNoSs
7111qvbfRC1aL8bky4XECct7Md1h7TTN/pY0E+6b1wI0gyHkCeuiaOfeq7I+lUogIzL
712SpuBTQvi59BdeLyTXImg8WCSKoLrZljdaEjCZdM51FWFvY2WdW1NE/ahniJpkGv5
713hcDj6qNPn8FHCLxzOs1HUucyncxbS9z6I91WaFWXWu0DH90lMA8gedyXZr6YOnkg
714H32P9zbIKaSiPxFg5JVRW5hpQWUI1dYr3CpKP4i98w==
715-----END CERTIFICATE-----
716"#;
717
718        let openldap_image = OpenLDAP::default()
719            .with_allow_anon_binding(false)
720            .with_user("maximiliane", "pwd1")
721            .with_tls(cert.to_string().into_bytes(), key.to_string().into_bytes())
722            .with_cert_ca(root_ca.to_string().into_bytes());
723        let node = openldap_image.start().await?;
724
725        let connection_string = format!(
726            "ldaps://{}:{}",
727            node.get_host().await?,
728            node.get_host_port_ipv4(OPENLDAPS_PORT).await?,
729        );
730
731        let mut builder = native_tls::TlsConnector::builder();
732        let root_ca = native_tls::Certificate::from_pem(root_ca.as_bytes())?;
733        let connector = builder.add_root_certificate(root_ca).build()?;
734
735        let settings = ldap3::LdapConnSettings::new().set_connector(connector);
736        let (conn, mut ldap) =
737            ldap3::LdapConnAsync::with_settings(settings, &connection_string).await?;
738
739        ldap3::drive!(conn);
740        ldap.simple_bind("cn=maximiliane,ou=users,dc=example,dc=org", "pwd1")
741            .await?
742            .success()?;
743        let users = read_users(&mut ldap, "(cn=*)", &["cn"]).await?;
744        assert_eq!(users.len(), 2); // cn=maximiliane and cn=readers
745        ldap.unbind().await?;
746        Ok(())
747    }
748}