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