frontegg 0.8.0

An async API client for the Frontegg user management service.
Documentation
// Copyright Materialize, Inc. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License in the LICENSE file at the
// root of this repository, or online at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Integration tests.
//!
//! To run these tests, you must create a Frontegg workspace and provide the
//! vendor credentials via `FRONTEGG_CLIENT_ID` and `FRONTEGG_SECRET_KEY`.
//!
//! These tests must be run serially, as via
//!
//!     $ cargo test -- --test-threads=1
//!
//! because each test competes for access to the same test Frontegg workspace.

use std::collections::HashSet;
use std::env;
use std::time::Duration;

use futures::stream::TryStreamExt;
use once_cell::sync::Lazy;
use reqwest::StatusCode;
use reqwest_retry::policies::ExponentialBackoff;
use serde_json::json;
use test_log::test;
use tracing::info;
use uuid::Uuid;
use wiremock::{matchers, Mock, MockServer, ResponseTemplate};

use frontegg::{
    ApiError, Client, ClientConfig, Error, TenantRequest, UserListConfig, UserListPartConfig,
    UserRequest,
};

pub static CLIENT_ID: Lazy<String> =
    Lazy::new(|| env::var("FRONTEGG_CLIENT_ID").expect("missing FRONTEGG_CLIENT_ID"));
pub static SECRET_KEY: Lazy<String> =
    Lazy::new(|| env::var("FRONTEGG_SECRET_KEY").expect("missing FRONTEGG_SECRET_KEY"));

const TENANT_NAME_PREFIX: &str = "test tenant";

fn new_client() -> Client {
    Client::builder()
        .with_retry_policy(
            ExponentialBackoff::builder()
                .retry_bounds(Duration::from_millis(500), Duration::from_secs(20))
                .build_with_max_retries(20),
        )
        .build(ClientConfig {
            client_id: CLIENT_ID.clone(),
            secret_key: SECRET_KEY.clone(),
        })
}

async fn delete_existing_tenants(client: &Client) {
    for tenant in client.list_tenants().await.unwrap() {
        if tenant.name.starts_with(TENANT_NAME_PREFIX) {
            info!(%tenant.id, "deleting existing tenant");
            client.delete_tenant(tenant.id).await.unwrap();
        }
    }
}

/// Tests that errors are retried automatically by the client for read API calls
/// but not for write API calls.
#[test(tokio::test)]
async fn test_retries_with_mock_server() {
    // Start a mock Frontegg API server and a client configured to target that
    // server. The retry policy disables backoff to speed up the tests.
    const MAX_RETRIES: u32 = 3;
    let server = MockServer::start().await;
    let client = Client::builder()
        .with_vendor_endpoint(server.uri().parse().unwrap())
        .with_retry_policy(
            ExponentialBackoff::builder()
                .retry_bounds(Duration::from_millis(1), Duration::from_millis(1))
                .build_with_max_retries(MAX_RETRIES),
        )
        .build(ClientConfig {
            client_id: "".into(),
            secret_key: "".into(),
        });

    // Register authentication handler.
    let mock = Mock::given(matchers::path("/auth/vendor"))
        .and(matchers::method("POST"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_string("{\"token\":\"test\", \"expiresIn\":2687784526}"),
        )
        .expect(1)
        .named("auth");
    server.register(mock).await;

    // Register a mock for the `get_tenant` call that returns a 429 response
    // code and ensure the client repeatedly retries the API call until giving
    // up after `MAX_RETRIES` retries and returning the error.
    let mock = Mock::given(matchers::method("GET"))
        .and(matchers::path_regex("/tenants/.*"))
        .respond_with(ResponseTemplate::new(429))
        .expect(u64::from(MAX_RETRIES) + 1)
        .named("get tenants");
    server.register(mock).await;
    let res = client.get_tenant(Uuid::new_v4()).await;
    assert!(res.is_err());

    // Register a mock for the `create_tenant` call that returns a 429 response
    // code and ensure the client only tries the API call once.
    let mock = Mock::given(matchers::method("POST"))
        .and(matchers::path_regex("/tenants/.*"))
        .respond_with(ResponseTemplate::new(429))
        .expect(1)
        .named("post tenants");
    server.register(mock).await;
    let _ = client
        .create_tenant(&TenantRequest {
            id: Uuid::new_v4(),
            name: &format!("{TENANT_NAME_PREFIX} 1"),
            metadata: json!({
                "tenant_number": 1,
            }),
            ..Default::default()
        })
        .await;
}

/// Tests basic functionality of creating and retrieving tenants and users.
#[test(tokio::test)]
async fn test_tenants_and_users() {
    // Set up.
    let client = new_client();
    delete_existing_tenants(&client).await;

    // Create two tenants.
    let tenant_id_1 = Uuid::new_v4();
    let tenant_id_2 = Uuid::new_v4();
    client
        .create_tenant(&TenantRequest {
            id: tenant_id_1,
            name: &format!("{TENANT_NAME_PREFIX} 1"),
            metadata: json!({
                "tenant_number": 1,
            }),
            creator_name: Some("tenant 1"),
            creator_email: Some("creator@tenant1.com"),
        })
        .await
        .unwrap();
    client
        .create_tenant(&TenantRequest {
            id: tenant_id_2,
            name: &format!("{TENANT_NAME_PREFIX} 2"),
            metadata: json!(42),
            ..Default::default()
        })
        .await
        .unwrap();

    // Verify tenant properties.
    let mut tenants: Vec<_> = client
        .list_tenants()
        .await
        .unwrap()
        .into_iter()
        .filter(|e| e.name.starts_with(TENANT_NAME_PREFIX))
        .collect();
    // Sort tenants by name to match order. Default ordering is by tenant ID.
    tenants.sort_by(|a, b| a.name.cmp(&b.name));
    assert_eq!(tenants.len(), 2);
    assert_eq!(tenants[0].id, tenant_id_1);
    assert_eq!(tenants[1].id, tenant_id_2);
    assert_eq!(tenants[0].name, format!("{TENANT_NAME_PREFIX} 1"));
    assert_eq!(tenants[1].name, format!("{TENANT_NAME_PREFIX} 2"));
    assert_eq!(tenants[0].metadata, json!({"tenant_number": 1}));
    assert_eq!(tenants[1].metadata, json!(42));
    assert_eq!(tenants[0].creator_name, Some("tenant 1".into()));
    assert_eq!(tenants[1].creator_name, None);
    assert_eq!(tenants[0].creator_email, Some("creator@tenant1.com".into()));
    assert_eq!(tenants[1].creator_email, None);
    assert_eq!(tenants[0].deleted_at, None);
    assert_eq!(tenants[1].deleted_at, None);

    // Verify a single tenant can be fetched by ID
    let tenant = client.get_tenant(tenants[0].id).await.unwrap();
    assert_eq!(tenant.id, tenants[0].id);

    // Verify an individual key can be added to tenant metadata
    client
        .set_tenant_metadata(
            tenants[0].id,
            &json!({
                "tenant_name": tenants[0].name,
            }),
        )
        .await
        .unwrap();
    let tenant = client.get_tenant(tenants[0].id).await.unwrap();
    assert_eq!(
        tenant.metadata,
        json!({"tenant_name": tenant.name, "tenant_number": 1})
    );

    // Verify an individual key within tenant metadata can be edited
    let set_tenant = client
        .set_tenant_metadata(tenants[0].id, &json!({"tenant_name": "set test"}))
        .await
        .unwrap();
    assert_eq!(
        set_tenant.metadata,
        json!({"tenant_name": "set test", "tenant_number": 1})
    );
    let tenant = client.get_tenant(tenants[0].id).await.unwrap();
    assert_eq!(
        tenant.metadata,
        json!({"tenant_name": "set test", "tenant_number": 1})
    );

    // Verify an individual key-value pair within tenant metadata can be deleted
    let delete_tenant = client
        .delete_tenant_metadata(tenants[0].id, "tenant_name")
        .await
        .unwrap();
    assert_eq!(delete_tenant.metadata, json!({"tenant_number": 1}));
    let tenant = client.get_tenant(tenants[0].id).await.unwrap();
    assert_eq!(tenant.metadata, json!({"tenant_number": 1}));

    // Verify an unknown tenant raises a suitable error
    let tenant_result = client
        .get_tenant(uuid::uuid!("00000000-0000-0000-0000-000000000000"))
        .await;
    match tenant_result {
        Err(Error::Api(ApiError { status_code, .. })) if status_code == StatusCode::NOT_FOUND => (),
        _ => panic!("unexpected response: {tenant_result:?}"),
    };

    // Create three users in each tenant.
    let mut users = vec![];
    for (tenant_idx, tenant) in tenants.iter().enumerate() {
        for user_idx in 0..3 {
            let name = format!("user-{tenant_idx}-{user_idx}");
            let email = format!("frontegg-test-{tenant_idx}-{user_idx}@example.com");
            let created_user = client
                .create_user(&UserRequest {
                    tenant_id: tenant.id,
                    name: &name,
                    email: &email,
                    skip_invite_email: true,
                    ..Default::default()
                })
                .await
                .unwrap();

            // Verify that the API has roundtripped the key properties.
            assert_eq!(created_user.name, name);
            assert_eq!(created_user.email, email);

            // Verify that fetching the same user by ID from the API returns
            // the same properties.
            let user = client.get_user(created_user.id).await.unwrap();
            assert_eq!(created_user.id, user.id);
            assert_eq!(user.name, name);
            assert_eq!(user.email, email);
            assert_eq!(user.tenants.len(), 1);
            assert_eq!(user.tenants[0].tenant_id, tenant.id);

            users.push(user);
        }
    }

    // Ensure that listing users works for a variety of page sizes.
    for page_size in [1, 2, 10] {
        let expected: HashSet<_> = users.iter().map(|u| u.id).collect();
        let actual: HashSet<_> = client
            .list_users(UserListConfig::default().page_size(page_size))
            .map_ok(|u| u.id)
            .try_collect()
            .await
            .unwrap();
        assert!(expected.difference(&actual).collect::<Vec<_>>().is_empty());
    }

    // Ensure that listing users parts works for a variety of sizes.
    let pages: Result<Vec<_>, Error> = client
        .list_users_part(
            UserListPartConfig::default()
                .page_size(1)
                .max_pages(1)
                .starting_page(0),
        )
        .map_ok(|u| u.id)
        .try_collect()
        .await;
    if let frontegg::Error::PaginationHault(v) = pages.as_ref().unwrap_err() {
        assert_eq!(v, &1);
    } else {
        panic!("{:?} should be PaginationHault Error", pages);
    };
    // Page should go up
    let pages: Result<Vec<_>, Error> = client
        .list_users_part(
            UserListPartConfig::default()
                .page_size(1)
                .max_pages(1)
                .starting_page(1),
        )
        .map_ok(|u| u.id)
        .try_collect()
        .await;
    if let frontegg::Error::PaginationHault(v) = pages.as_ref().unwrap_err() {
        assert_eq!(v, &2);
    } else {
        panic!("{:?} should be PaginationHault Error", pages);
    };

    // Should act like normal stream if max not hit
    let pages: Result<Vec<_>, Error> = client
        .list_users_part(
            UserListPartConfig::default()
                .page_size(100)
                .max_pages(100)
                .starting_page(0),
        )
        .map_ok(|u| u.id)
        .try_collect()
        .await;
    assert!(pages.is_ok());

    // Ensure that the user list can be filtered to a single tenant.
    {
        let expected: HashSet<_> = users.iter().take(3).map(|u| u.id).collect();
        let actual: HashSet<_> = client
            .list_users(UserListConfig::default().tenant_id(tenant_id_1))
            .map_ok(|u| u.id)
            .try_collect()
            .await
            .unwrap();
        assert_eq!(expected, actual);
    }

    // Delete all users;
    for user in &users {
        client.delete_user(user.id).await.unwrap();
    }

    // Verify that users are really gone.
    {
        let users: Vec<_> = client
            .list_users(Default::default())
            .try_collect::<Vec<_>>()
            .await
            .unwrap()
            .into_iter()
            .filter(|u| u.email.starts_with("frontegg-test-"))
            .collect();
        assert_eq!(users.len(), 0);
    }
}