Skip to main content

osp_cli/
api.rs

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