use std::collections::HashSet;
use account_management_sdk::{
IdpDeprovisionTenantRequest, IdpDeprovisionUserRequest, IdpListUsersRequest, IdpNewUser,
IdpPluginClient, IdpProvisionTenantRequest, IdpProvisionUserRequest, IdpTenantContext,
IdpUserFilterField, IdpUserOperationFailure, IdpUserPagination,
};
use serde_json::{Value, json};
use toolkit_odata::filter::{FilterNode, FilterOp, ODataValue};
use toolkit_security::SecurityContext;
use uuid::Uuid;
use crate::domain::Service;
fn ctx() -> SecurityContext {
SecurityContext::anonymous()
}
const TENANT_TYPE: &str = "gts.cf.core.am.tenant_type.v1~cf.core.am.customer.v1~";
fn tenant_type() -> gts::GtsTypeId {
gts::GtsTypeId::new(TENANT_TYPE)
}
fn tenant_ctx(tenant_id: Uuid) -> IdpTenantContext {
IdpTenantContext::new(tenant_id, "static-idp-plugin-test", tenant_type(), None)
}
fn req(tenant_id: Uuid, top: u32, cursor: Option<&str>) -> IdpListUsersRequest {
let pagination =
IdpUserPagination::new(top, cursor.map(str::to_owned)).expect("pagination shape is valid");
IdpListUsersRequest::new(tenant_ctx(tenant_id), pagination)
}
fn seed(svc: &Service, tenant_id: Uuid, count: usize) {
for i in 0..count {
let payload = IdpNewUser::new(format!("user-{i:03}"));
let user = Service::echo_user(tenant_id, &payload);
svc.record_user(tenant_id, user);
}
}
#[tokio::test]
async fn empty_snapshot_returns_empty_page_without_cursors() {
let svc = Service::new();
let page = svc
.list_users(&ctx(), &req(Uuid::new_v4(), 50, None))
.await
.expect("empty list");
assert!(page.items.is_empty());
assert!(page.page_info.next_cursor.is_none());
assert!(page.page_info.prev_cursor.is_none());
}
#[tokio::test]
async fn page_size_at_least_snapshot_returns_one_page_no_next() {
let svc = Service::new();
let tenant = Uuid::new_v4();
seed(&svc, tenant, 3);
let page = svc
.list_users(&ctx(), &req(tenant, 10, None))
.await
.expect("page");
assert_eq!(page.items.len(), 3);
assert!(page.page_info.next_cursor.is_none());
assert!(page.page_info.prev_cursor.is_none());
}
#[tokio::test]
async fn cursor_walk_covers_full_snapshot_without_loss_or_duplication() {
let svc = Service::new();
let tenant = Uuid::new_v4();
seed(&svc, tenant, 7);
let top = 3;
let mut seen: HashSet<Uuid> = HashSet::new();
let mut cursor: Option<String> = None;
let mut pages: usize = 0;
loop {
let page = svc
.list_users(&ctx(), &req(tenant, top, cursor.as_deref()))
.await
.expect("paged list");
assert!(
!page.items.is_empty(),
"every page in the walk MUST carry at least one row"
);
for user in &page.items {
assert!(
seen.insert(user.id),
"cursor walk produced a duplicate user id {} across pages",
user.id,
);
}
pages += 1;
match page.page_info.next_cursor {
Some(c) => cursor = Some(c),
None => break,
}
assert!(pages < 10, "cursor walk failed to terminate");
}
assert_eq!(seen.len(), 7, "cursor walk MUST surface every seeded user");
assert_eq!(pages, 3, "7 rows at top=3 -> 3 pages (3 + 3 + 1)");
}
#[tokio::test]
async fn final_page_carries_no_forward_or_backward_cursor() {
let svc = Service::new();
let tenant = Uuid::new_v4();
seed(&svc, tenant, 3);
let page1 = svc
.list_users(&ctx(), &req(tenant, 2, None))
.await
.expect("page 1");
assert_eq!(page1.items.len(), 2);
let cur = page1
.page_info
.next_cursor
.clone()
.expect("page 1 must carry a forward cursor (3 rows / top=2)");
let page2 = svc
.list_users(&ctx(), &req(tenant, 2, Some(cur.as_str())))
.await
.expect("page 2");
assert_eq!(page2.items.len(), 1, "final page carries the remaining row");
assert!(
page2.page_info.next_cursor.is_none(),
"final page MUST NOT carry a forward cursor"
);
assert!(
page2.page_info.prev_cursor.is_none(),
"plugin is forward-only; prev_cursor MUST always be None"
);
}
#[tokio::test]
async fn invalid_cursor_surfaces_as_rejected() {
let svc = Service::new();
let tenant = Uuid::new_v4();
seed(&svc, tenant, 1);
let err = svc
.list_users(
&ctx(),
&req(tenant, 10, Some("not-a-valid-base64-cursor!!!")),
)
.await
.expect_err("malformed cursor MUST be rejected");
assert!(
matches!(err, IdpUserOperationFailure::Rejected { .. }),
"expected Rejected on malformed cursor, got {err:?}",
);
}
#[tokio::test]
async fn provision_tenant_root_returns_echo_metadata() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
let request = IdpProvisionTenantRequest::for_root(tenant_id, "root-corp", tenant_type());
let result = svc
.provision_tenant(&ctx(), &request)
.await
.expect("provision ok");
let metadata = result
.metadata
.expect("provision_tenant MUST emit Some metadata");
assert_eq!(metadata["echo"], json!(true));
assert_eq!(metadata["tenant_id"], json!(tenant_id));
assert_eq!(metadata["tenant_name"], json!("root-corp"));
assert_eq!(metadata["tenant_type"], json!(TENANT_TYPE));
assert_eq!(metadata["target"], json!("root"));
assert_eq!(metadata["parent_id"], Value::Null);
assert_eq!(metadata["provisioning_metadata"], Value::Null);
}
#[tokio::test]
async fn provision_tenant_child_carries_parent_id_and_echoed_provisioning_metadata() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
let parent_id = Uuid::new_v4();
let request = IdpProvisionTenantRequest::new(tenant_id, parent_id, "acme", tenant_type())
.with_metadata(json!({"realm": "acme-keycloak", "region": "eu-west-1"}));
let result = svc
.provision_tenant(&ctx(), &request)
.await
.expect("provision ok");
let metadata = result
.metadata
.expect("provision_tenant MUST emit Some metadata");
assert_eq!(metadata["target"], json!("child"));
assert_eq!(metadata["parent_id"], json!(parent_id));
assert_eq!(
metadata["provisioning_metadata"],
json!({"realm": "acme-keycloak", "region": "eu-west-1"}),
"provisioning_metadata MUST be echoed verbatim",
);
}
#[tokio::test]
async fn provision_tenant_is_deterministic_across_invocations() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
let parent_id = Uuid::new_v4();
let request = IdpProvisionTenantRequest::new(tenant_id, parent_id, "acme", tenant_type());
let a = svc.provision_tenant(&ctx(), &request).await.expect("first");
let b = svc
.provision_tenant(&ctx(), &request)
.await
.expect("second");
assert_eq!(
a.metadata, b.metadata,
"echo metadata MUST be a pure function of the input request"
);
}
#[tokio::test]
async fn deprovision_tenant_always_succeeds() {
let svc = Service::new();
let request = IdpDeprovisionTenantRequest::new(tenant_ctx(Uuid::new_v4()));
svc.deprovision_tenant(&ctx(), &request)
.await
.expect("deprovision MUST succeed");
}
#[tokio::test]
async fn provision_user_records_user_and_returns_deterministic_id() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
let payload = IdpNewUser::new("alice")
.with_email("alice@example.com")
.with_display_name("Alice");
let request = IdpProvisionUserRequest::new(tenant_ctx(tenant_id), payload);
let user_a = svc.provision_user(&ctx(), &request).await.expect("first");
let user_b = svc.provision_user(&ctx(), &request).await.expect("second");
assert_eq!(user_a.id, user_b.id, "same input MUST yield same UUIDv5");
assert_eq!(user_a.username, "alice");
assert_eq!(user_a.email.as_deref(), Some("alice@example.com"));
assert_eq!(user_a.display_name.as_deref(), Some("Alice"));
let page = svc
.list_users(&ctx(), &req(tenant_id, 10, None))
.await
.expect("list");
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].id, user_a.id);
}
#[tokio::test]
async fn provision_user_different_tenants_yield_different_ids() {
let svc = Service::new();
let tenant_a = Uuid::new_v4();
let tenant_b = Uuid::new_v4();
let payload = IdpNewUser::new("alice");
let ua = svc
.provision_user(
&ctx(),
&IdpProvisionUserRequest::new(tenant_ctx(tenant_a), payload.clone()),
)
.await
.expect("a");
let ub = svc
.provision_user(
&ctx(),
&IdpProvisionUserRequest::new(tenant_ctx(tenant_b), payload),
)
.await
.expect("b");
assert_ne!(
ua.id, ub.id,
"tenant scope MUST namespace the derived user id"
);
}
#[tokio::test]
async fn provision_user_re_provision_overwrites_with_new_payload() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
let req_one = IdpProvisionUserRequest::new(
tenant_ctx(tenant_id),
IdpNewUser::new("bob").with_email("bob@old.example.com"),
);
let req_two = IdpProvisionUserRequest::new(
tenant_ctx(tenant_id),
IdpNewUser::new("bob")
.with_email("bob@new.example.com")
.with_display_name("Bob"),
);
let first = svc.provision_user(&ctx(), &req_one).await.expect("first");
let second = svc.provision_user(&ctx(), &req_two).await.expect("second");
assert_eq!(first.id, second.id);
let page = svc
.list_users(&ctx(), &req(tenant_id, 10, None))
.await
.expect("list");
assert_eq!(
page.items.len(),
1,
"re-provision MUST overwrite, not append"
);
assert_eq!(
page.items[0].email.as_deref(),
Some("bob@new.example.com"),
"post-overwrite snapshot MUST reflect the new payload"
);
assert_eq!(page.items[0].display_name.as_deref(), Some("Bob"));
}
#[tokio::test]
async fn deprovision_user_removes_existing_user() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
let payload = IdpNewUser::new("carol");
let user = svc
.provision_user(
&ctx(),
&IdpProvisionUserRequest::new(tenant_ctx(tenant_id), payload),
)
.await
.expect("provision");
svc.deprovision_user(
&ctx(),
&IdpDeprovisionUserRequest::new(tenant_ctx(tenant_id), user.id),
)
.await
.expect("deprovision");
let page = svc
.list_users(&ctx(), &req(tenant_id, 10, None))
.await
.expect("list");
assert!(
page.items.is_empty(),
"deprovision MUST remove the row from the per-tenant cache"
);
}
#[tokio::test]
async fn deprovision_user_is_idempotent_when_already_absent() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
svc.deprovision_user(
&ctx(),
&IdpDeprovisionUserRequest::new(tenant_ctx(tenant_id), Uuid::new_v4()),
)
.await
.expect("absent deprovision MUST be Ok");
}
#[tokio::test]
async fn list_users_with_id_eq_filter_returns_single_row_or_empty() {
let svc = Service::new();
let tenant_id = Uuid::new_v4();
let user = svc
.provision_user(
&ctx(),
&IdpProvisionUserRequest::new(tenant_ctx(tenant_id), IdpNewUser::new("dave")),
)
.await
.expect("provision");
let hit_pagination = IdpUserPagination::new(50, None).expect("pagination");
let hit = svc
.list_users(
&ctx(),
&IdpListUsersRequest::new(tenant_ctx(tenant_id), hit_pagination).with_filter(
FilterNode::binary(
IdpUserFilterField::Id,
FilterOp::Eq,
ODataValue::Uuid(user.id),
),
),
)
.await
.expect("filtered list hit");
assert_eq!(hit.items.len(), 1);
assert_eq!(hit.items[0].id, user.id);
let miss_pagination = IdpUserPagination::new(50, None).expect("pagination");
let miss = svc
.list_users(
&ctx(),
&IdpListUsersRequest::new(tenant_ctx(tenant_id), miss_pagination).with_filter(
FilterNode::binary(
IdpUserFilterField::Id,
FilterOp::Eq,
ODataValue::Uuid(Uuid::new_v4()),
),
),
)
.await
.expect("filtered list miss");
assert!(
miss.items.is_empty(),
"id eq filter on absent id MUST surface an empty page"
);
}
#[tokio::test]
async fn provisioned_user_round_trips_first_last_name_through_list_users() {
let svc = Service::new();
let tenant = Uuid::new_v4();
let req_provision = IdpProvisionUserRequest::new(
tenant_ctx(tenant),
IdpNewUser::new("alice")
.with_first_name("Alice")
.with_last_name("Anderson"),
);
let provisioned = svc
.provision_user(&ctx(), &req_provision)
.await
.expect("provision succeeds");
assert_eq!(provisioned.first_name.as_deref(), Some("Alice"));
assert_eq!(provisioned.last_name.as_deref(), Some("Anderson"));
let page = svc
.list_users(&ctx(), &req(tenant, 10, None))
.await
.expect("list succeeds");
let echoed = page
.items
.iter()
.find(|u| u.username == "alice")
.expect("alice surfaces in list");
assert_eq!(echoed.first_name.as_deref(), Some("Alice"));
assert_eq!(echoed.last_name.as_deref(), Some("Anderson"));
}
#[tokio::test]
async fn list_users_filter_eq_username_returns_only_matching_user() {
use account_management_sdk::IdpUserFilterField;
use toolkit_odata::filter::{FilterNode, FilterOp, ODataValue};
let svc = Service::new();
let tenant = Uuid::new_v4();
for (uname, fname) in [("alice", "Alice"), ("bob", "Bob")] {
let req = IdpProvisionUserRequest::new(
tenant_ctx(tenant),
IdpNewUser::new(uname).with_first_name(fname),
);
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let req = IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(50, None).expect("valid pagination"),
)
.with_filter(FilterNode::binary(
IdpUserFilterField::Username,
FilterOp::Eq,
ODataValue::String("alice".into()),
));
let page = svc.list_users(&ctx(), &req).await.expect("list");
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].username, "alice");
}
#[tokio::test]
async fn list_users_filter_contains_first_name_is_case_insensitive() {
use account_management_sdk::IdpUserFilterField;
use toolkit_odata::filter::{FilterNode, FilterOp, ODataValue};
let svc = Service::new();
let tenant = Uuid::new_v4();
for (uname, fname) in [("alice", "Alice"), ("bob", "Bob")] {
let req = IdpProvisionUserRequest::new(
tenant_ctx(tenant),
IdpNewUser::new(uname).with_first_name(fname),
);
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let req = IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(50, None).expect("valid pagination"),
)
.with_filter(FilterNode::binary(
IdpUserFilterField::FirstName,
FilterOp::Contains,
ODataValue::String("ali".into()),
));
let page = svc.list_users(&ctx(), &req).await.expect("list");
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].username, "alice");
}
#[tokio::test]
async fn list_users_default_order_is_username_asc_with_id_tiebreaker() {
let svc = Service::new();
let tenant = Uuid::new_v4();
for uname in ["carl", "alice", "bob"] {
let req = IdpProvisionUserRequest::new(tenant_ctx(tenant), IdpNewUser::new(uname));
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let req = IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(50, None).expect("valid pagination"),
);
let page = svc.list_users(&ctx(), &req).await.expect("list");
let names: Vec<&str> = page.items.iter().map(|u| u.username.as_str()).collect();
assert_eq!(names, vec!["alice", "bob", "carl"], "default username ASC");
}
#[tokio::test]
async fn list_users_caller_order_last_name_desc_sorts_correctly_with_id_tiebreaker() {
use toolkit_odata::{ODataOrderBy, OrderKey, SortDir};
let svc = Service::new();
let tenant = Uuid::new_v4();
for (uname, lname) in [("u1", "Charlie"), ("u2", "Alpha"), ("u3", "Bravo")] {
let req = IdpProvisionUserRequest::new(
tenant_ctx(tenant),
IdpNewUser::new(uname).with_last_name(lname),
);
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let req = IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(50, None).expect("valid pagination"),
)
.with_order(ODataOrderBy(vec![OrderKey {
field: "last_name".into(),
dir: SortDir::Desc,
}]));
let page = svc.list_users(&ctx(), &req).await.expect("list");
let names: Vec<&str> = page.items.iter().map(|u| u.username.as_str()).collect();
assert_eq!(names, vec!["u1", "u3", "u2"], "C, B, A by last_name desc");
}
#[tokio::test]
async fn list_users_order_id_eq_tiebreaker_is_idempotent() {
use toolkit_odata::{ODataOrderBy, OrderKey, SortDir};
let svc = Service::new();
let tenant = Uuid::new_v4();
for uname in ["c", "a", "b"] {
let req = IdpProvisionUserRequest::new(tenant_ctx(tenant), IdpNewUser::new(uname));
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let req = IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(50, None).expect("valid pagination"),
)
.with_order(ODataOrderBy(vec![OrderKey {
field: "id".into(),
dir: SortDir::Asc,
}]));
let page = svc.list_users(&ctx(), &req).await.expect("list");
assert_eq!(page.items.len(), 3);
}
#[tokio::test]
async fn list_users_filter_and_composite_returns_intersection() {
use account_management_sdk::IdpUserFilterField;
use toolkit_odata::filter::{FilterNode, FilterOp, ODataValue};
let svc = Service::new();
let tenant = Uuid::new_v4();
let seed = [
("alice", "A", "Anderson"),
("alex", "A", "Brown"),
("bob", "B", "Anderson"),
];
for (uname, fname, lname) in seed {
let req = IdpProvisionUserRequest::new(
tenant_ctx(tenant),
IdpNewUser::new(uname)
.with_first_name(fname)
.with_last_name(lname),
);
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let req = IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(50, None).expect("valid pagination"),
)
.with_filter(FilterNode::and(vec![
FilterNode::binary(
IdpUserFilterField::FirstName,
FilterOp::Eq,
ODataValue::String("A".into()),
),
FilterNode::binary(
IdpUserFilterField::LastName,
FilterOp::Eq,
ODataValue::String("Anderson".into()),
),
]));
let page = svc.list_users(&ctx(), &req).await.expect("list");
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].username, "alice");
}
#[tokio::test]
async fn list_users_filtered_ordered_cursor_continues_across_pages() {
use account_management_sdk::IdpUserFilterField;
use toolkit_odata::filter::{FilterNode, FilterOp, ODataValue};
let svc = Service::new();
let tenant = Uuid::new_v4();
for (uname, fname) in [
("u_a", "X"),
("u_b", "X"),
("u_c", "X"),
("u_d", "X"),
("u_e", "X"),
("noise_1", "Y"),
("noise_2", "Y"),
] {
let req = IdpProvisionUserRequest::new(
tenant_ctx(tenant),
IdpNewUser::new(uname).with_first_name(fname),
);
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let mk_req = |cursor: Option<String>| {
IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(2, cursor).expect("valid pagination"),
)
.with_filter(FilterNode::binary(
IdpUserFilterField::FirstName,
FilterOp::Eq,
ODataValue::String("X".into()),
))
};
let p1 = svc.list_users(&ctx(), &mk_req(None)).await.expect("p1");
let p1_names: Vec<&str> = p1.items.iter().map(|u| u.username.as_str()).collect();
assert_eq!(p1_names, vec!["u_a", "u_b"]);
let cur1 = p1.page_info.next_cursor.expect("page1 has next cursor");
let p2 = svc
.list_users(&ctx(), &mk_req(Some(cur1)))
.await
.expect("p2");
let p2_names: Vec<&str> = p2.items.iter().map(|u| u.username.as_str()).collect();
assert_eq!(p2_names, vec!["u_c", "u_d"]);
let cur2 = p2.page_info.next_cursor.expect("page2 has next cursor");
let p3 = svc
.list_users(&ctx(), &mk_req(Some(cur2)))
.await
.expect("p3");
let p3_names: Vec<&str> = p3.items.iter().map(|u| u.username.as_str()).collect();
assert_eq!(p3_names, vec!["u_e"]);
assert!(
p3.page_info.next_cursor.is_none(),
"final page has no next cursor"
);
}
#[tokio::test]
async fn cursor_with_drifted_orderby_surfaces_as_rejected() {
use toolkit_odata::{ODataOrderBy, OrderKey, SortDir};
let svc = Service::new();
let tenant = Uuid::new_v4();
for uname in ["alice", "bob", "carl"] {
let req = IdpProvisionUserRequest::new(tenant_ctx(tenant), IdpNewUser::new(uname));
svc.provision_user(&ctx(), &req).await.expect("provision");
}
let p1 = svc
.list_users(
&ctx(),
&IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(1, None).expect("valid pagination"),
),
)
.await
.expect("page 1");
let cur1 = p1
.page_info
.next_cursor
.expect("page 1 emits a next cursor");
let drifted = IdpListUsersRequest::new(
tenant_ctx(tenant),
IdpUserPagination::new(1, Some(cur1)).expect("valid pagination"),
)
.with_order(ODataOrderBy(vec![OrderKey {
field: "last_name".into(),
dir: SortDir::Asc,
}]));
let err = svc
.list_users(&ctx(), &drifted)
.await
.expect_err("order drift MUST be rejected");
let IdpUserOperationFailure::Rejected { detail } = err else {
panic!("expected Rejected on order drift, got {err:?}");
};
assert!(
detail.contains("$filter / $orderby"),
"rejection detail mentions the contract: got {detail:?}"
);
}