kanidmd_testkit/
lib.rs

1#![deny(warnings)]
2#![warn(unused_extern_crates)]
3#![deny(clippy::todo)]
4#![deny(clippy::unimplemented)]
5#![deny(clippy::unwrap_used)]
6#![deny(clippy::panic)]
7#![deny(clippy::unreachable)]
8#![deny(clippy::await_holding_lock)]
9#![deny(clippy::needless_pass_by_value)]
10#![deny(clippy::trivially_copy_pass_by_ref)]
11
12use kanidm_client::{KanidmClient, KanidmClientBuilder};
13use kanidm_proto::internal::{CURegState, Filter, Modify, ModifyList};
14use kanidmd_core::config::{Configuration, IntegrationTestConfig};
15use kanidmd_core::{create_server_core, CoreHandle};
16use kanidmd_lib::prelude::{Attribute, NAME_SYSTEM_ADMINS};
17use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
18use std::str::FromStr;
19use std::sync::atomic::{AtomicU16, Ordering};
20use tokio::task;
21use tracing::error;
22use url::Url;
23use webauthn_authenticator_rs::softpasskey::SoftPasskey;
24use webauthn_authenticator_rs::WebauthnAuthenticator;
25
26pub const ADMIN_TEST_USER: &str = "admin";
27pub const ADMIN_TEST_PASSWORD: &str = "integration test admin password";
28pub const IDM_ADMIN_TEST_USER: &str = "idm_admin";
29pub const IDM_ADMIN_TEST_PASSWORD: &str = "integration idm admin password";
30
31pub const NOT_ADMIN_TEST_USERNAME: &str = "krab_test_user";
32pub const NOT_ADMIN_TEST_PASSWORD: &str = "eicieY7ahchaoCh0eeTa";
33pub const NOT_ADMIN_TEST_EMAIL: &str = "krab_test@example.com";
34
35pub static PORT_ALLOC: AtomicU16 = AtomicU16::new(18080);
36
37pub const TEST_INTEGRATION_RS_ID: &str = "test_integration";
38pub const TEST_INTEGRATION_RS_GROUP_ALL: &str = "idm_all_accounts";
39pub const TEST_INTEGRATION_RS_DISPLAY: &str = "Test Integration";
40pub const TEST_INTEGRATION_RS_URL: &str = "https://demo.example.com";
41pub const TEST_INTEGRATION_RS_REDIRECT_URL: &str = "https://demo.example.com/oauth2/flow";
42pub const TEST_INTEGRATION_STATE_VALUE: &str = "KrabzRc0ol";
43
44pub use testkit_macros::test;
45use tracing::trace;
46
47pub fn is_free_port(port: u16) -> bool {
48    TcpStream::connect(("0.0.0.0", port)).is_err()
49}
50
51// Test external behaviours of the service.
52fn port_loop() -> u16 {
53    let mut counter = 0;
54    loop {
55        let possible_port = PORT_ALLOC.fetch_add(1, Ordering::SeqCst);
56        if is_free_port(possible_port) {
57            break possible_port;
58        }
59        counter += 1;
60        #[allow(clippy::panic)]
61        if counter >= 5 {
62            tracing::error!("Unable to allocate port!");
63            panic!();
64        }
65    }
66}
67
68pub struct AsyncTestEnvironment {
69    pub rsclient: KanidmClient,
70    pub http_sock_addr: SocketAddr,
71    pub core_handle: CoreHandle,
72    pub ldap_url: Option<Url>,
73}
74
75// allowed because the use of this function is behind a test gate
76#[allow(dead_code)]
77pub async fn setup_async_test(mut config: Configuration) -> AsyncTestEnvironment {
78    sketching::test_init();
79
80    let port = port_loop();
81
82    let int_config = Box::new(IntegrationTestConfig {
83        admin_user: ADMIN_TEST_USER.to_string(),
84        admin_password: ADMIN_TEST_PASSWORD.to_string(),
85        idm_admin_user: IDM_ADMIN_TEST_USER.to_string(),
86        idm_admin_password: IDM_ADMIN_TEST_PASSWORD.to_string(),
87    });
88
89    #[allow(clippy::expect_used)]
90    let addr =
91        Url::from_str(&format!("http://localhost:{port}")).expect("Failed to parse origin URL");
92
93    let ldap_url = if config.ldapbindaddress.is_some() {
94        let ldapport = port_loop();
95        let ldap_sock_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), ldapport);
96        config.ldapbindaddress = Some(ldap_sock_addr.to_string());
97        Url::parse(&format!("ldap://{ldap_sock_addr}"))
98            .inspect_err(|err| error!(?err, "ldap address setup"))
99            .ok()
100    } else {
101        None
102    };
103
104    // Setup the address and origin..
105    let http_sock_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), port);
106
107    config.address = http_sock_addr.to_string();
108    config.integration_test_config = Some(int_config);
109    config.domain = "localhost".to_string();
110    config.origin.clone_from(&addr);
111
112    let core_handle = match create_server_core(config, false).await {
113        Ok(val) => val,
114        #[allow(clippy::panic)]
115        Err(_) => panic!("failed to start server core"),
116    };
117    // We have to yield now to guarantee that the elements are setup.
118    task::yield_now().await;
119
120    #[allow(clippy::panic)]
121    let rsclient = match KanidmClientBuilder::new()
122        .address(addr.to_string())
123        .enable_native_ca_roots(false)
124        .no_proxy()
125        .build()
126    {
127        Ok(val) => val,
128        Err(_) => panic!("failed to build client"),
129    };
130
131    tracing::info!("Testkit server setup complete - {}", addr);
132
133    AsyncTestEnvironment {
134        rsclient,
135        http_sock_addr,
136        core_handle,
137        ldap_url,
138    }
139}
140
141pub async fn setup_account_passkey(
142    rsclient: &KanidmClient,
143    account_name: &str,
144) -> WebauthnAuthenticator<SoftPasskey> {
145    // Create an intent token for them
146    let intent_token = rsclient
147        .idm_person_account_credential_update_intent(account_name, Some(1234))
148        .await
149        .expect("Unable to setup account passkey");
150
151    // Create a new empty session.
152    let rsclient = rsclient
153        .new_session()
154        .expect("Unable to create new client session");
155
156    // Exchange the intent token
157    let (session_token, _status) = rsclient
158        .idm_account_credential_update_exchange(intent_token.token)
159        .await
160        .expect("Unable to exchange credential update token");
161
162    let _status = rsclient
163        .idm_account_credential_update_status(&session_token)
164        .await
165        .expect("Unable to check credential update status");
166
167    // Setup and update the passkey
168    let mut wa = WebauthnAuthenticator::new(SoftPasskey::new(true));
169
170    let status = rsclient
171        .idm_account_credential_update_passkey_init(&session_token)
172        .await
173        .expect("Unable to init passkey update");
174
175    let passkey_chal = match status.mfaregstate {
176        CURegState::Passkey(c) => Some(c),
177        _ => None,
178    }
179    .expect("Unable to access passkey challenge, invalid state");
180
181    eprintln!("{}", rsclient.get_origin());
182    let passkey_resp = wa
183        .do_registration(rsclient.get_origin().clone(), passkey_chal)
184        .expect("Failed to create soft passkey");
185
186    let label = "Soft Passkey".to_string();
187
188    let status = rsclient
189        .idm_account_credential_update_passkey_finish(&session_token, label, passkey_resp)
190        .await
191        .expect("Unable to finish passkey credential");
192
193    assert!(status.can_commit);
194    assert_eq!(status.passkeys.len(), 1);
195
196    // Commit it
197    rsclient
198        .idm_account_credential_update_commit(&session_token)
199        .await
200        .expect("Unable to commit credential update");
201
202    // Assert it now works.
203    let _ = rsclient.logout().await;
204
205    wa
206}
207
208/// creates a user (username: `id`) and puts them into a group, creating it if need be.
209pub async fn create_user(rsclient: &KanidmClient, id: &str, group_name: &str) {
210    #[allow(clippy::expect_used)]
211    rsclient
212        .idm_person_account_create(id, id)
213        .await
214        .expect("Failed to create the user");
215
216    // Create group and add to user to test read attr: member_of
217    #[allow(clippy::panic)]
218    if rsclient
219        .idm_group_get(group_name)
220        .await
221        .unwrap_or_else(|_| panic!("Failed to get group {group_name}"))
222        .is_none()
223    {
224        #[allow(clippy::panic)]
225        rsclient
226            .idm_group_create(group_name, None)
227            .await
228            .unwrap_or_else(|_| panic!("Failed to create group {group_name}"));
229    }
230    #[allow(clippy::panic)]
231    rsclient
232        .idm_group_add_members(group_name, &[id])
233        .await
234        .unwrap_or_else(|_| panic!("Failed to add user {id} to group {group_name}"));
235}
236
237pub async fn create_user_with_all_attrs(
238    rsclient: &KanidmClient,
239    id: &str,
240    optional_group: Option<&str>,
241) {
242    let group_format = format!("{id}_group");
243    let group_name = optional_group.unwrap_or(&group_format);
244
245    create_user(rsclient, id, group_name).await;
246    add_all_attrs(rsclient, id, group_name, Some(id)).await;
247}
248
249pub async fn add_all_attrs(
250    rsclient: &KanidmClient,
251    id: &str,
252    group_name: &str,
253    legalname: Option<&str>,
254) {
255    // Extend with posix attrs to test read attr: gidnumber and loginshell
256    #[allow(clippy::expect_used)]
257    rsclient
258        .idm_person_account_unix_extend(id, None, Some("/bin/sh"))
259        .await
260        .expect("Failed to set shell to /bin/sh for user");
261    #[allow(clippy::expect_used)]
262    rsclient
263        .idm_group_unix_extend(group_name, None)
264        .await
265        .expect("Failed to extend user group");
266
267    for attr in [Attribute::SshPublicKey, Attribute::Mail].into_iter() {
268        println!("Checking writable for {attr}");
269        #[allow(clippy::expect_used)]
270        let res = is_attr_writable(rsclient, id, attr)
271            .await
272            .expect("Failed to get writable status for attribute");
273        assert!(res);
274    }
275
276    if let Some(legalname) = legalname {
277        #[allow(clippy::expect_used)]
278        let res = is_attr_writable(rsclient, legalname, Attribute::LegalName)
279            .await
280            .expect("Failed to get writable status for legalname field");
281        assert!(res);
282    }
283
284    // Write radius credentials
285    if id != "anonymous" {
286        login_account(rsclient, id).await;
287        #[allow(clippy::expect_used)]
288        let _ = rsclient
289            .idm_account_radius_credential_regenerate(id)
290            .await
291            .expect("Failed to regen password for user");
292
293        #[allow(clippy::expect_used)]
294        rsclient
295            .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
296            .await
297            .expect("Failed to auth with password as admin!");
298    }
299}
300
301pub async fn is_attr_writable(rsclient: &KanidmClient, id: &str, attr: Attribute) -> Option<bool> {
302    println!("writing to attribute: {attr}");
303    match attr {
304        Attribute::RadiusSecret => Some(
305            rsclient
306                .idm_account_radius_credential_regenerate(id)
307                .await
308                .is_ok(),
309        ),
310        Attribute::PrimaryCredential => Some(
311            rsclient
312                .idm_person_account_primary_credential_set_password(id, "dsadjasiodqwjk12asdl")
313                .await
314                .is_ok(),
315        ),
316        Attribute::SshPublicKey => Some(
317            rsclient
318                .idm_person_account_post_ssh_pubkey(
319                    id,
320                    "k1",
321                    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAeGW1P6Pc2rPq0XqbRaDKBcXZUPRklo0\
322                     L1EyR30CwoP william@amethyst",
323                )
324                .await
325                .is_ok(),
326        ),
327        Attribute::UnixPassword => Some(
328            rsclient
329                .idm_person_account_unix_cred_put(id, "dsadjasiodqwjk12asdl")
330                .await
331                .is_ok(),
332        ),
333        Attribute::LegalName => Some(
334            rsclient
335                .idm_person_account_set_attr(
336                    id,
337                    Attribute::LegalName.as_ref(),
338                    &["test legal name"],
339                )
340                .await
341                .is_ok(),
342        ),
343        Attribute::Mail => Some(
344            rsclient
345                .idm_person_account_set_attr(
346                    id,
347                    Attribute::Mail.as_ref(),
348                    &[&format!("{id}@example.com")],
349                )
350                .await
351                .is_ok(),
352        ),
353        ref entry => {
354            let new_value = match entry {
355                Attribute::AcpReceiverGroup => "00000000-0000-0000-0000-000000000011".to_string(),
356                Attribute::AcpTargetScope => "{\"and\": [{\"eq\": [\"class\",\"access_control_profile\"]}, {\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}".to_string(),
357                 _ => id.to_string(),
358            };
359            let m = ModifyList::new_list(vec![
360                Modify::Purged(attr.to_string()),
361                Modify::Present(attr.to_string(), new_value),
362            ]);
363            let f = Filter::Eq(Attribute::Name.to_string(), id.to_string());
364            Some(rsclient.modify(f.clone(), m.clone()).await.is_ok())
365        }
366    }
367}
368
369pub async fn login_account(rsclient: &KanidmClient, id: &str) {
370    #[allow(clippy::expect_used)]
371    rsclient
372        .idm_person_account_primary_credential_set_password(id, NOT_ADMIN_TEST_PASSWORD)
373        .await
374        .expect("Failed to set password for user");
375
376    let _ = rsclient.logout().await;
377    let res = rsclient
378        .auth_simple_password(id, NOT_ADMIN_TEST_PASSWORD)
379        .await;
380
381    // Setup privs
382    println!("{id} logged in");
383    assert!(res.is_ok());
384
385    let res = rsclient
386        .reauth_simple_password(NOT_ADMIN_TEST_PASSWORD)
387        .await;
388    println!("{id} priv granted for");
389    assert!(res.is_ok());
390}
391
392// Login to the given account, but first login with default admin credentials.
393// This is necessary when switching between unprivileged accounts, but adds extra calls which
394// create extra debugging noise, so should be avoided when unnecessary.
395pub async fn login_account_via_admin(rsclient: &KanidmClient, id: &str) {
396    let _ = rsclient.logout().await;
397
398    #[allow(clippy::expect_used)]
399    rsclient
400        .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
401        .await
402        .expect("Failed to login as admin!");
403    login_account(rsclient, id).await
404}
405
406pub async fn test_read_attrs(
407    rsclient: &KanidmClient,
408    id: &str,
409    attrs: &[Attribute],
410    is_readable: bool,
411) {
412    println!("Test read to {id}, is readable: {is_readable}");
413    #[allow(clippy::expect_used)]
414    let rset = rsclient
415        .search(Filter::Eq(Attribute::Name.to_string(), id.to_string()))
416        .await
417        .expect("Can't get user from search");
418
419    #[allow(clippy::expect_used)]
420    let e = rset.first().expect("Failed to get first user from set");
421
422    for attr in attrs.iter() {
423        trace!("Reading {}", attr);
424        #[allow(clippy::unwrap_used)]
425        let is_ok = match *attr {
426            Attribute::RadiusSecret => rsclient
427                .idm_account_radius_credential_get(id)
428                .await
429                .unwrap()
430                .is_some(),
431            _ => e.attrs.contains_key(attr.as_str()),
432        };
433        trace!("is_ok: {}, is_readable: {}", is_ok, is_readable);
434        assert_eq!(is_ok, is_readable)
435    }
436}
437
438pub async fn test_write_attrs(
439    rsclient: &KanidmClient,
440    id: &str,
441    attrs: &[Attribute],
442    is_writeable: bool,
443) {
444    println!("Test write to {id}, is writeable: {is_writeable}");
445    for attr in attrs.iter() {
446        println!("Writing to {attr} - ex {is_writeable}");
447        #[allow(clippy::unwrap_used)]
448        let is_ok = is_attr_writable(rsclient, id, attr.clone()).await.unwrap();
449        assert_eq!(is_ok, is_writeable)
450    }
451}
452
453pub async fn test_modify_group(
454    rsclient: &KanidmClient,
455    group_names: &[&str],
456    can_be_modified: bool,
457) {
458    // need user test created to be added as test part
459    for group in group_names.iter() {
460        println!("Testing group: {group}");
461        for attr in [Attribute::Description, Attribute::Name].into_iter() {
462            #[allow(clippy::unwrap_used)]
463            let is_writable = is_attr_writable(rsclient, group, attr.clone())
464                .await
465                .unwrap();
466            dbg!(group, attr, is_writable, can_be_modified);
467            assert_eq!(is_writable, can_be_modified)
468        }
469        assert!(
470            rsclient
471                .idm_group_add_members(group, &[NOT_ADMIN_TEST_USERNAME])
472                .await
473                .is_ok()
474                == can_be_modified
475        );
476    }
477}
478
479/// Logs in with the admin user and puts them in idm_admins so they can do admin things
480pub async fn login_put_admin_idm_admins(rsclient: &KanidmClient) {
481    #[allow(clippy::expect_used)]
482    rsclient
483        .auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
484        .await
485        .expect("Failed to authenticate as admin!");
486
487    #[allow(clippy::expect_used)]
488    rsclient
489        .idm_group_add_members(NAME_SYSTEM_ADMINS, &[ADMIN_TEST_USER])
490        .await
491        .expect("Failed to add admin user to idm_admins")
492}
493
494#[macro_export]
495macro_rules! assert_no_cache {
496    ($response:expr) => {{
497        // Check we have correct nocache headers.
498        let cache_header: &str = $response
499            .headers()
500            .get(kanidm_client::http::header::CACHE_CONTROL)
501            .expect("missing cache-control header")
502            .to_str()
503            .expect("invalid cache-control header");
504
505        assert!(cache_header.contains("no-store"));
506        assert!(cache_header.contains("max-age=0"));
507
508        let pragma_header: &str = $response
509            .headers()
510            .get("pragma")
511            .expect("missing cache-control header")
512            .to_str()
513            .expect("invalid cache-control header");
514
515        assert!(pragma_header.contains("no-cache"));
516    }};
517}