Skip to main content

osp_cli/ports/
mock.rs

1//! Test and example doubles for the service-layer ports.
2//!
3//! These helpers exist to make the public port surfaces easy to demonstrate
4//! and exercise without bootstrapping the full host stack.
5
6use std::collections::HashMap;
7
8use anyhow::Result;
9
10use crate::core::row::Row;
11
12use super::{LdapDirectory, apply_filter_and_projection};
13
14/// In-memory LDAP test double used by examples, unit tests, and service tests.
15///
16/// The fixture data intentionally stays small and deterministic so callers can
17/// exercise filtering, wildcard lookup, and projection behavior without
18/// talking to a real directory. The default fixture currently contains one
19/// `oistes` user entry and one `ucore` netgroup entry.
20///
21/// # Examples
22///
23/// ```
24/// use osp_cli::ports::LdapDirectory;
25/// use osp_cli::ports::mock::MockLdapClient;
26///
27/// let ldap = MockLdapClient::default();
28/// let rows = ldap.user("oistes", Some("uid=oistes"), None).unwrap();
29///
30/// assert_eq!(rows.len(), 1);
31/// assert_eq!(rows[0].get("uid").and_then(|value| value.as_str()), Some("oistes"));
32/// ```
33#[derive(Debug, Clone)]
34pub struct MockLdapClient {
35    users: HashMap<String, Row>,
36    netgroups: HashMap<String, Row>,
37}
38
39impl Default for MockLdapClient {
40    fn default() -> Self {
41        let mut users = HashMap::new();
42        users.insert(
43            "oistes".to_string(),
44            crate::row! {
45                "uid" => "oistes",
46                "cn" => "Øistein Søvik",
47                "uidNumber" => "361000",
48                "gidNumber" => "346297",
49                "homeDirectory" => "/uio/kant/usit-gsd-u1/oistes",
50                "loginShell" => "/local/gnu/bin/bash",
51                "objectClass" => ["uioMembership", "top", "account", "posixAccount"],
52                "eduPersonAffiliation" => ["employee", "member", "staff"],
53                "uioAffiliation" => "ANSATT@373034",
54                "uioPrimaryAffiliation" => "ANSATT@373034",
55                "netgroups" => ["ucore", "usit", "it-uio-azure-users"],
56                "filegroups" => ["oistes", "ucore", "usit"]
57            },
58        );
59
60        let mut netgroups = HashMap::new();
61        netgroups.insert(
62            "ucore".to_string(),
63            crate::row! {
64                "cn" => "ucore",
65                "description" => "Kjernen av Unix-grupp på USIT",
66                "objectClass" => ["top", "nisNetgroup"],
67                "members" => [
68                    "andreasd",
69                    "arildlj",
70                    "kjetilk",
71                    "oistes",
72                    "trondham",
73                    "werner"
74                ]
75            },
76        );
77
78        Self { users, netgroups }
79    }
80}
81
82impl LdapDirectory for MockLdapClient {
83    fn user(
84        &self,
85        uid: &str,
86        filter: Option<&str>,
87        attributes: Option<&[String]>,
88    ) -> Result<Vec<Row>> {
89        let raw_rows = if uid.contains('*') {
90            self.users
91                .iter()
92                .filter(|(key, _)| wildcard_match(uid, key))
93                .map(|(_, row)| row.clone())
94                .collect::<Vec<Row>>()
95        } else {
96            self.users
97                .get(uid)
98                .cloned()
99                .map(|row| vec![row])
100                .unwrap_or_default()
101        };
102
103        Ok(apply_filter_and_projection(raw_rows, filter, attributes))
104    }
105
106    fn netgroup(
107        &self,
108        name: &str,
109        filter: Option<&str>,
110        attributes: Option<&[String]>,
111    ) -> Result<Vec<Row>> {
112        let raw_rows = if name.contains('*') {
113            self.netgroups
114                .iter()
115                .filter(|(key, _)| wildcard_match(name, key))
116                .map(|(_, row)| row.clone())
117                .collect::<Vec<Row>>()
118        } else {
119            self.netgroups
120                .get(name)
121                .cloned()
122                .map(|row| vec![row])
123                .unwrap_or_default()
124        };
125
126        Ok(apply_filter_and_projection(raw_rows, filter, attributes))
127    }
128}
129
130fn wildcard_match(pattern: &str, value: &str) -> bool {
131    let escaped = regex::escape(pattern).replace("\\*", ".*");
132    match regex::Regex::new(&format!("^{escaped}$")) {
133        Ok(re) => re.is_match(value),
134        Err(_) => false,
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::MockLdapClient;
141    use crate::ports::LdapDirectory;
142
143    #[test]
144    fn user_filter_uid_equals_returns_match() {
145        let ldap = MockLdapClient::default();
146        let rows = ldap
147            .user("oistes", Some("uid=oistes"), None)
148            .expect("query should succeed");
149        assert_eq!(rows.len(), 1);
150    }
151
152    #[test]
153    fn wildcard_queries_match_users_and_netgroups() {
154        let ldap = MockLdapClient::default();
155
156        let users = ldap.user("oi*", None, None).expect("query should succeed");
157        assert_eq!(users.len(), 1);
158        assert_eq!(
159            users[0].get("uid").and_then(|value| value.as_str()),
160            Some("oistes")
161        );
162
163        let netgroups = ldap
164            .netgroup("u*", None, Some(&["cn".to_string()]))
165            .expect("query should succeed");
166        assert_eq!(netgroups.len(), 1);
167        assert_eq!(
168            netgroups[0].get("cn").and_then(|value| value.as_str()),
169            Some("ucore")
170        );
171        assert_eq!(netgroups[0].len(), 1);
172    }
173
174    #[test]
175    fn missing_entries_return_empty_results() {
176        let ldap = MockLdapClient::default();
177
178        let users = ldap
179            .user("does-not-exist", Some("uid=does-not-exist"), None)
180            .expect("query should succeed");
181        assert!(users.is_empty());
182
183        let netgroups = ldap
184            .netgroup("nope*", None, None)
185            .expect("query should succeed");
186        assert!(netgroups.is_empty());
187    }
188
189    #[test]
190    fn exact_netgroup_queries_return_single_match() {
191        let ldap = MockLdapClient::default();
192
193        let netgroups = ldap
194            .netgroup("ucore", None, Some(&["cn".to_string()]))
195            .expect("query should succeed");
196
197        assert_eq!(netgroups.len(), 1);
198        assert_eq!(
199            netgroups[0].get("cn").and_then(|value| value.as_str()),
200            Some("ucore")
201        );
202    }
203}