Browse Source

test(scim): cover minting, rollback, disabled key, and group edges

Seed tokens through the real mint_scim_token path; add a minted-token round-trip
plus rotation-invalidates-old-token test; add a disabled-key case to the
uniform-401 matrix; unit-test rollback_provisioning (pre-existing user survives,
shell user cascades); and add group externalId-uniqueness and omitted-vs-empty
members regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/7443/head
David Croft 3 days ago
parent
commit
90bf2ba9a1
  1. 191
      src/api/scim/tests/mod.rs

191
src/api/scim/tests/mod.rs

@ -67,12 +67,11 @@ async fn seed_org(conn: &DbConn, name: &str) -> OrganizationId {
org.uuid
}
// Mirrors manage::generate_scim_key and returns the full bearer token.
// Mints through the real management path (manage::mint_scim_token), so the
// token format and the guard's verification cannot drift apart between
// production and tests.
async fn seed_scim_key(conn: &DbConn, org_uuid: &OrganizationId) -> String {
let secret = crypto::encode_random_bytes::<32>(&data_encoding::BASE64URL_NOPAD);
let key_hash = crypto::sha256_hex(secret.as_bytes());
ScimApiKey::new(org_uuid.clone(), key_hash).save(conn).await.expect("saving scim key");
format!("scim_v1.{org_uuid}.{secret}")
scim::manage::mint_scim_token(org_uuid, conn).await.expect("minting scim key").0
}
fn bearer(token: &str) -> Header<'static> {
@ -148,6 +147,22 @@ async fn auth_failures_are_uniform_401s() {
.dispatch()
.await,
));
// A key row that exists but is disabled (the column is reserved for a
// future disable-without-deleting endpoint) must be rejected identically,
// even with the correct secret.
let org_d = seed_org(&conn, "scim-authz-d").await;
let secret_d = crypto::encode_random_bytes::<32>(&data_encoding::BASE64URL_NOPAD);
let mut key_d = ScimApiKey::new(org_d.clone(), crypto::sha256_hex(secret_d.as_bytes()));
key_d.enabled = false;
key_d.save(&conn).await.expect("saving disabled scim key");
failures.push((
"disabled key with correct secret",
client
.get(format!("/scim/v2/{org_d}/ServiceProviderConfig"))
.header(bearer(&format!("scim_v1.{org_d}.{secret_d}")))
.dispatch()
.await,
));
let mut bodies = Vec::new();
for (case, response) in failures {
@ -165,6 +180,28 @@ async fn auth_failures_are_uniform_401s() {
assert!(first.contains("\"status\":\"401\""));
}
#[rocket::async_test]
async fn minted_token_round_trips_and_rotation_kills_the_old_one() {
let _guard = TEST_LOCK.lock().await;
let (client, pool) = scim_client().await;
let conn = pool.get().await.expect("conn");
let org = seed_org(&conn, "scim-mint-org").await;
// Mint through the real management path and authenticate with the result.
let (token, _) = scim::manage::mint_scim_token(&org, &conn).await.expect("minting");
let response = client.get(format!("/scim/v2/{org}/ServiceProviderConfig")).header(bearer(&token)).dispatch().await;
assert_eq!(response.status(), Status::Ok);
// Rotation replaces the key row: the old token dies instantly, the new
// one works.
let (rotated, _) = scim::manage::mint_scim_token(&org, &conn).await.expect("rotating");
let response = client.get(format!("/scim/v2/{org}/ServiceProviderConfig")).header(bearer(&token)).dispatch().await;
assert_eq!(response.status(), Status::Unauthorized, "rotation must invalidate the previous token");
let response =
client.get(format!("/scim/v2/{org}/ServiceProviderConfig")).header(bearer(&rotated)).dispatch().await;
assert_eq!(response.status(), Status::Ok);
}
#[rocket::async_test]
async fn unknown_scim_route_is_scim_enveloped_404() {
let _guard = TEST_LOCK.lock().await;
@ -254,7 +291,7 @@ async fn post_creates_invited_user() {
client.post(format!("/scim/v2/{org}/Users")).header(auth).header(content_type).body(body).dispatch().await;
assert_eq!(response.status(), Status::Created);
let location = response.headers().get_one("Location").expect("Location header").to_string();
let location = response.headers().get_one("Location").expect("Location header").to_owned();
let parsed = parse_json(&body_of(response).await);
// Mail is disabled and the user is new (no password): Invited (0).
@ -657,6 +694,41 @@ async fn put_does_not_change_email_or_role() {
assert_eq!(member_status(&conn, &member, &org).await, 2);
}
#[rocket::async_test]
async fn provisioning_rollback_spares_preexisting_users() {
let _guard = TEST_LOCK.lock().await;
let (_client, pool) = scim_client().await;
let conn = pool.get().await.expect("conn");
let org = seed_org(&conn, "scim-rollback-org").await;
// A pre-existing account: rollback removes only the new membership. This
// is the branch that must never delete someone's account on a transient
// invite-mail failure.
let user = seed_user(&conn, "rollback.existing@example.com", true).await;
let user_uuid = user.uuid.clone();
let mut member = Membership::new(user.uuid.clone(), org.clone(), None);
member.status = 0;
member.save(&conn).await.expect("saving membership");
let member_uuid = member.uuid.clone();
scim::users::rollback_provisioning(user, member, false, &conn).await;
assert!(User::find_by_uuid(&user_uuid, &conn).await.is_some(), "pre-existing user must survive rollback");
assert!(Membership::find_by_uuid_and_org(&member_uuid, &org, &conn).await.is_none(), "membership must be gone");
// A shell account created by this request: rollback removes the user,
// which cascades to the membership.
let user = seed_user(&conn, "rollback.shell@example.com", false).await;
let user_uuid = user.uuid.clone();
let mut member = Membership::new(user.uuid.clone(), org.clone(), None);
member.status = 0;
member.save(&conn).await.expect("saving membership");
let member_uuid = member.uuid.clone();
scim::users::rollback_provisioning(user, member, true, &conn).await;
assert!(User::find_by_uuid(&user_uuid, &conn).await.is_none(), "shell user must be removed");
assert!(Membership::find_by_uuid_and_org(&member_uuid, &org, &conn).await.is_none(), "membership must cascade");
}
#[rocket::async_test]
async fn pagination_edges() {
let _guard = TEST_LOCK.lock().await;
@ -843,3 +915,110 @@ async fn group_put_replaces_member_set() {
assert_eq!(members.len(), 1);
assert_eq!(members[0]["value"], json!(member_b.to_string()));
}
#[rocket::async_test]
async fn group_put_omitted_members_kept_but_empty_list_clears() {
let _guard = TEST_LOCK.lock().await;
let (client, pool) = scim_client().await;
let conn = pool.get().await.expect("conn");
let org = seed_org(&conn, "scim-group-sparse-put-org").await;
let token = seed_scim_key(&conn, &org).await;
let member_a = seed_member(&conn, &org, "sparse.a@example.com", 1, MembershipType::User).await;
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "Sparse",
"members": [{"value": member_a}],
});
let (auth, ct, body) = scim_body(&token, &payload);
let response = client.post(format!("/scim/v2/{org}/Groups")).header(auth).header(ct).body(body).dispatch().await;
let group_id = parse_json(&body_of(response).await)["id"].as_str().expect("id").to_owned();
// A PUT that omits the members attribute renames without touching the
// member set: a sparse client must not be able to wipe a group by accident.
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "Sparse Renamed",
});
let (auth, ct, body) = scim_body(&token, &payload);
let response =
client.put(format!("/scim/v2/{org}/Groups/{group_id}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["displayName"], json!("Sparse Renamed"));
assert_eq!(parsed["members"].as_array().expect("members").len(), 1, "omitted members must keep membership");
// An explicit empty list is a real replacement and clears the group.
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "Sparse Renamed",
"members": [],
});
let (auth, ct, body) = scim_body(&token, &payload);
let response =
client.put(format!("/scim/v2/{org}/Groups/{group_id}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
let parsed = parse_json(&body_of(response).await);
assert_eq!(parsed["members"].as_array().expect("members").len(), 0, "empty members must clear membership");
}
#[rocket::async_test]
async fn group_external_id_uniqueness_enforced_on_put_and_patch() {
let _guard = TEST_LOCK.lock().await;
let (client, pool) = scim_client().await;
let conn = pool.get().await.expect("conn");
let org = seed_org(&conn, "scim-group-extid-org").await;
let token = seed_scim_key(&conn, &org).await;
let mut group_ids = Vec::new();
for (name, ext) in [("Ext One", "ext-1"), ("Ext Two", "ext-2")] {
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": name,
"externalId": ext,
});
let (auth, ct, body) = scim_body(&token, &payload);
let response =
client.post(format!("/scim/v2/{org}/Groups")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Created);
group_ids.push(parse_json(&body_of(response).await)["id"].as_str().expect("id").to_owned());
}
let group_two = &group_ids[1];
// PATCH onto a taken externalId is a 409, and must not change the group.
let payload = json!({
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "externalId", "value": "ext-1"}],
});
let (auth, ct, body) = scim_body(&token, &payload);
let response =
client.patch(format!("/scim/v2/{org}/Groups/{group_two}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Conflict);
assert_eq!(parse_json(&body_of(response).await)["scimType"], json!("uniqueness"));
// PUT onto a taken externalId is the same 409.
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "Ext Two",
"externalId": "ext-1",
});
let (auth, ct, body) = scim_body(&token, &payload);
let response =
client.put(format!("/scim/v2/{org}/Groups/{group_two}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Conflict);
let response = client.get(format!("/scim/v2/{org}/Groups/{group_two}")).header(bearer(&token)).dispatch().await;
assert_eq!(parse_json(&body_of(response).await)["externalId"], json!("ext-2"), "conflict must not partially apply");
// Re-asserting a group's own externalId stays a no-op success: Entra
// repeats it on every PUT.
let payload = json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "Ext Two",
"externalId": "ext-2",
});
let (auth, ct, body) = scim_body(&token, &payload);
let response =
client.put(format!("/scim/v2/{org}/Groups/{group_two}")).header(auth).header(ct).body(body).dispatch().await;
assert_eq!(response.status(), Status::Ok);
}

Loading…
Cancel
Save