Files
inbuxa-server/tests/src/scim/acceptance.rs
T
jcoffey-dev 3df72b355a SCIM tests: the authenticated rate limit, and the compat check on INBUXA's data (SCIM-14, test 31)
A principal's key without unlimitedRequests gets 429 with Retry-After
over the limit, while its unlimited key still works. scim_compat, ignored,
checks a copy of INBUXA's data reads back as observed: no domain open to
SCIM and no externalId.
2026-09-19 09:53:36 -07:00

1651 lines
55 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! SCIM acceptance tests, from `docs/spec/features/scim.md`. Each check
//! names its test number or requirement.
use crate::{
scim::{
PRINCIPAL_SECRET, SCIM_DOMAIN, ScimClient, ScimTest, api_key, api_key_with_id,
create_principal, full_permissions, group_body, patch_body, query, user_body,
},
utils::{account::Account, server::TestServer, smtp::SmtpConnection},
};
use registry::{
schema::{
enums::{Permission, TenantStorageQuota},
prelude::{ObjectType, Property},
structs::{
self, Action, CertificateManagement, DataRetention, DkimManagement, DnsManagement,
Domain, PasswordCredential, Permissions, PermissionsList, Tenant, UserAccount,
},
},
types::{duration::Duration, list::List, map::Map},
};
use scim_proto::{
MESSAGE_BULK_REQUEST, MESSAGE_SEARCH_REQUEST, SCHEMA_ENTERPRISE_USER, SCHEMA_USER,
};
use serde_json::json;
use std::str::FromStr;
use trc::{Collector, MetricType};
use types::id::Id;
use utils::map::vec_map::VecMap;
const CLOSED: &str = "closed.example.com";
const USER_SECRET: &str = "scim acceptance user passphrase";
pub async fn test(test: &TestServer, scim: &ScimTest) {
println!("Running SCIM acceptance tests...");
let admin = test.account("admin");
let closed_id = admin
.registry_create_object(Domain {
is_enabled: true,
name: CLOSED.to_string(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
..Default::default()
})
.await;
admin.registry_create_object(Action::InvalidateCaches).await;
discovery(scim).await;
authentication(test, scim).await;
domains(test, scim, closed_id).await;
users(test, scim).await;
groups(scim).await;
patching(scim).await;
conditional(scim).await;
queries(scim).await;
bulk(scim).await;
suspension(test, scim).await;
deletion(test, scim).await;
adoption(test, scim).await;
tenants(test, scim).await;
admin
.registry_destroy(ObjectType::Domain, [closed_id])
.await;
}
/// Test 10 (SCIM-2 to SCIM-6).
async fn discovery(scim: &ScimTest) {
let anonymous = ScimClient::anonymous();
let config = anonymous.get("/ServiceProviderConfig").await;
config.assert_status(200);
assert_eq!(
config.header("content-type").as_deref(),
Some("application/scim+json")
);
assert_eq!(config.json["bulk"]["maxOperations"], json!(1000), "SCIM-4");
assert_eq!(
config.json["bulk"]["maxPayloadSize"],
json!(1048576),
"SCIM-4"
);
assert_eq!(config.json["filter"]["maxResults"], json!(200), "SCIM-4");
assert_eq!(
config.json["changePassword"]["supported"],
json!(false),
"SCIM-4"
);
assert_eq!(
config.json["pagination"]["cursorTimeout"],
json!(3600),
"SCIM-4"
);
assert_eq!(
config.json["interopProfileConformant"],
json!(false),
"SCIM-4"
);
assert!(
!config.body.contains("stalw"),
"SCIM-4: documentation is INBUXA's own"
);
let types = anonymous.get("/ResourceTypes").await;
types.assert_status(200);
assert_eq!(types.total_results(), 2, "SCIM-5");
assert!(!types.body.contains("schemaExtensions\":[{"), "SCIM-5");
anonymous
.get("/ResourceTypes/User")
.await
.assert_status(200);
let schemas = anonymous.get("/Schemas").await;
schemas.assert_status(200);
assert!(!schemas.body.contains("\"password\""), "SCIM-6");
anonymous
.get(&format!("/Schemas/{SCHEMA_USER}"))
.await
.assert_status(200);
anonymous
.get("/Schemas?filter=id%20eq%20%22x%22")
.await
.assert_error(403, None);
anonymous.get("/Nothing").await.assert_error(404, None);
let wrong = anonymous
.request(reqwest::Method::DELETE, "/ServiceProviderConfig", None, &[])
.await;
wrong.assert_error(405, None);
assert!(wrong.header("allow").is_some(), "SCIM-2: Allow");
let options = anonymous
.request(reqwest::Method::OPTIONS, "/Users", None, &[])
.await;
assert_eq!(options.status, 204, "SCIM-2");
anonymous.get("/Me").await.assert_error(501, None);
scim.client.get("/Me").await.assert_error(501, None);
}
/// Tests 6, 7, 8 (SCIM-7, SCIM-9, SCIM-11, SCIM-13, SCIM-52).
async fn authentication(test: &TestServer, scim: &ScimTest) {
let admin = test.account("admin");
// Test 6: missing, Basic, a bearer that isn't an API key
for client in [
ScimClient::anonymous(),
ScimClient::with_authorization(Some(format!(
"Basic {}",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
format!("scim-svc@{SCIM_DOMAIN}:{PRINCIPAL_SECRET}")
)
))),
ScimClient::bearer("not-an-api-key"),
ScimClient::bearer("API_bm90IGEgcmVhbCBrZXk"),
] {
let reply = client.get("/Users").await;
reply.assert_error(401, None);
assert!(
reply
.header("www-authenticate")
.is_some_and(|h| h.starts_with("Bearer")),
"test 6: {}",
reply.body
);
}
let basic = ScimClient::with_authorization(Some("Basic eDp5".into()))
.get("/Users")
.await;
basic.assert_detail_contains("Bearer");
// A key from a disallowed address, and a deleted key
let principal = Account::new(
"[email protected]",
PRINCIPAL_SECRET,
&[],
"",
scim.principal_id,
);
let (fenced_id, fenced) = api_key_with_id(&principal, json!({"@type": "Inherit"})).await;
principal
.jmap_update(
"x:ApiKey",
[(
fenced_id.to_string(),
json!({"allowedIps": {"10.9.9.9": true}}),
)],
Vec::<(&str, &str)>::new(),
)
.await;
ScimClient::bearer(&fenced)
.get("/Users")
.await
.assert_error(401, None);
let (doomed_id, doomed) = api_key_with_id(&principal, json!({"@type": "Inherit"})).await;
ScimClient::bearer(&doomed)
.get("/Users")
.await
.assert_status(200);
principal
.registry_destroy(ObjectType::ApiKey, [doomed_id])
.await;
ScimClient::bearer(&doomed)
.get("/Users")
.await
.assert_error(401, None);
// A Replace key without scimAccess: 403 naming it
let narrow = api_key(
admin,
&principal,
json!({"@type": "Replace", "permissions": {"authenticate": true, "sysAccountGet": true}}),
)
.await;
ScimClient::bearer(&narrow)
.get("/Users")
.await
.assert_error(403, None)
.assert_detail_contains("scimAccess");
// Test 7: no sysAccountDestroy: DELETE refused, suspension works
let no_destroy = api_key(
admin,
&principal,
json!({"@type": "Disable", "permissions": {"sysAccountDestroy": true}}),
)
.await;
let limited = ScimClient::bearer(&no_destroy);
let id = scim.create_user(&format!("leaver@{SCIM_DOMAIN}")).await;
limited
.delete(&format!("/Users/{id}"))
.await
.assert_error(403, None)
.assert_detail_contains("sysAccountDestroy");
let patched = limited
.patch(
&format!("/Users/{id}"),
patch_body(json!([{"op": "replace", "path": "active", "value": false}])),
)
.await;
patched.assert_status(200);
assert_eq!(patched.json["active"], json!(false), "test 7");
scim.destroy(&format!("/Users/{id}")).await;
// Test 8: the principal can't deactivate, rename or delete itself
let me = format!("/Users/{}", scim.principal_id);
let before = scim.client.get(&me).await;
before.assert_status(200);
for body in [
patch_body(json!([{"op": "replace", "path": "active", "value": false}])),
patch_body(
json!([{"op": "replace", "path": "userName", "value": format!("other@{SCIM_DOMAIN}")}]),
),
] {
scim.client.patch(&me, body).await.assert_error(403, None);
}
scim.client.delete(&me).await.assert_error(403, None);
let after = scim.client.get(&me).await;
assert_eq!(after.etag(), before.etag(), "test 8: unchanged");
}
/// Tests 11, 12, 13 (SCIM-15, SCIM-16, SCIM-18).
async fn domains(test: &TestServer, scim: &ScimTest, closed_id: Id) {
let admin = test.account("admin");
// Test 11
scim.client
.post("/Users", user_body(&format!("nobody@{CLOSED}")))
.await
.assert_error(400, Some("invalidValue"))
.assert_detail_contains(CLOSED);
scim.client
.post(
"/Users",
json!({
"schemas": [SCHEMA_USER],
"userName": format!("half@{SCIM_DOMAIN}"),
"emails": [{"value": format!("half@{CLOSED}")}],
}),
)
.await
.assert_error(400, Some("invalidValue"));
scim.client
.get(&query(
"/Users",
&format!("userName eq \"half@{SCIM_DOMAIN}\""),
))
.await
.assert_status(200);
scim.client
.post("/Users", user_body("not an address"))
.await
.assert_error(400, Some("invalidValue"))
.assert_detail_contains("is not a valid email address");
// Test 12: an account on a closed domain isn't listed
let hidden = admin
.registry_create_object(structs::Account::User(UserAccount {
name: "hidden".to_string(),
domain_id: closed_id,
..Default::default()
}))
.await;
scim.client
.get("/Users?count=200")
.await
.assert_status(200)
.assert_lacks_id(&hidden.to_string());
scim.client
.get(&format!("/Users/{hidden}"))
.await
.assert_error(404, None);
// Test 13: a principal on a closed domain can't create groups
let outsider_id =
create_principal(admin, "outside-svc", closed_id, None, full_permissions()).await;
let outsider = Account::new(
"[email protected]",
PRINCIPAL_SECRET,
&[],
"",
outsider_id,
);
let token = api_key(admin, &outsider, json!({"@type": "Inherit"})).await;
let client = ScimClient::bearer(&token);
client
.post("/Groups", group_body("Closed Team"))
.await
.assert_error(400, Some("invalidValue"))
.assert_detail_contains(CLOSED);
let id = client
.post("/Users", user_body(&format!("managed@{SCIM_DOMAIN}")))
.await
.assert_status(201)
.id();
scim.destroy(&format!("/Users/{id}")).await;
admin
.registry_destroy(ObjectType::Account, [hidden, outsider_id])
.await;
}
/// Tests 15 to 20 (SCIM-21 to SCIM-29).
async fn users(test: &TestServer, scim: &ScimTest) {
let admin = test.account("admin");
// Test 16: a duplicate address is 409 and changes nothing
let first = scim
.client
.post(
"/Users",
json!({
"schemas": [SCHEMA_USER],
"userName": format!("Jane.Doe@{SCIM_DOMAIN}"),
"displayName": "Jane Doe",
"emails": [
{"value": format!("jane.doe@{SCIM_DOMAIN}"), "primary": true, "type": "work"},
{"value": format!("jd@{SCIM_DOMAIN}")},
{"value": format!("JD@{SCIM_DOMAIN}"), "type": "home"},
],
}),
)
.await;
first.assert_status(201);
let jane = first.id();
assert_eq!(
first.json["userName"],
json!(format!("jane.doe@{SCIM_DOMAIN}")),
"SCIM-22"
);
assert!(
first.header("location").is_some_and(|l| l.ends_with(&jane)),
"SCIM-39"
);
assert!(
first.etag().is_some_and(|e| e.starts_with("W/\"")),
"SCIM-44"
);
assert!(first.json["meta"].get("lastModified").is_none(), "SCIM-30");
// Test 17: primary first, read-only; the duplicate alias skipped
assert_eq!(
first.json["emails"],
json!([
{"value": format!("jane.doe@{SCIM_DOMAIN}"), "type": "work", "primary": true},
{"value": format!("jd@{SCIM_DOMAIN}"), "primary": false},
]),
"test 17"
);
scim.client
.post("/Users", user_body(&format!("JANE.DOE@{SCIM_DOMAIN}")))
.await
.assert_error(409, Some("uniqueness"));
scim.client
.post("/Users", user_body(&format!("jd@{SCIM_DOMAIN}")))
.await
.assert_error(409, Some("uniqueness"));
let unchanged = scim.client.get(&format!("/Users/{jane}")).await;
assert_eq!(unchanged.etag(), first.etag(), "test 16");
// Test 17: the primary through emails is 400 mutability; an alias
// another account holds is 409; PATCH remove drops an alias
scim.client
.patch(
&format!("/Users/{jane}"),
patch_body(json!([{
"op": "remove",
"path": format!("emails[value eq \"jane.doe@{SCIM_DOMAIN}\"]"),
}])),
)
.await
.assert_error(400, Some("mutability"))
.assert_detail_contains("userName");
let other = scim.create_user(&format!("other@{SCIM_DOMAIN}")).await;
scim.client
.patch(
&format!("/Users/{other}"),
patch_body(json!([{"op": "add", "path": "emails", "value": [{"value": format!("jd@{SCIM_DOMAIN}")}]}])),
)
.await
.assert_error(409, Some("uniqueness"));
let dropped = scim
.client
.patch(
&format!("/Users/{jane}"),
patch_body(
json!([{"op": "remove", "path": format!("emails[value eq \"jd@{SCIM_DOMAIN}\"]")}]),
),
)
.await;
dropped.assert_status(200);
assert_eq!(
dropped.json["emails"].as_array().unwrap().len(),
1,
"test 17"
);
// Test 15: a rename moves the account; the old address is released
let renamed = scim
.client
.patch(
&format!("/Users/{jane}"),
patch_body(json!([{"op": "replace", "path": "userName", "value": format!("jane.smith@{SCIM_DOMAIN}")}])),
)
.await;
renamed.assert_status(200);
assert_eq!(renamed.json["id"], json!(jane), "SCIM-21");
assert_eq!(
renamed.json["emails"].as_array().unwrap().len(),
1,
"SCIM-23"
);
let mut lmtp = SmtpConnection::connect().await;
lmtp.mail_from("[email protected]", 2).await;
lmtp.rcpt_to(&format!("jane.doe@{SCIM_DOMAIN}"), 5).await;
lmtp.rcpt_to(&format!("jane.smith@{SCIM_DOMAIN}"), 2).await;
lmtp.quit().await;
// Test 18: locales and time zones
for (sent, stored) in [
(json!({"locale": "EN-us"}), "en-US"),
(json!({"locale": "ca-ES@valencia"}), "ca-ES-valencia"),
(json!({"preferredLanguage": "fr-FR"}), "fr-FR"),
(
json!({"locale": "de-DE", "preferredLanguage": "fr-FR"}),
"de-DE",
),
] {
let mut body = user_body(&format!("jane.smith@{SCIM_DOMAIN}"));
body.as_object_mut()
.unwrap()
.extend(sent.as_object().unwrap().clone());
let reply = scim.client.put(&format!("/Users/{jane}"), body).await;
reply.assert_status(200);
assert_eq!(reply.json["locale"], json!(stored), "test 18 {sent}");
assert_eq!(
reply.json["preferredLanguage"],
json!(stored),
"test 18 {sent}"
);
}
for bad in [
json!({"locale": "xx-YY"}),
json!({"timezone": "Mars/Olympus"}),
] {
let mut body = user_body(&format!("jane.smith@{SCIM_DOMAIN}"));
body.as_object_mut()
.unwrap()
.extend(bad.as_object().unwrap().clone());
scim.client
.put(&format!("/Users/{jane}"), body)
.await
.assert_error(400, Some("invalidValue"));
}
let mut body = user_body(&format!("jane.smith@{SCIM_DOMAIN}"));
body["timezone"] = json!("europe/madrid");
let reply = scim.client.put(&format!("/Users/{jane}"), body).await;
assert_eq!(reply.json["timezone"], json!("Europe/Madrid"), "test 18");
// SCIM-24: the display name by precedence, and returned twice
let reply = scim
.client
.put(
&format!("/Users/{jane}"),
json!({
"schemas": [SCHEMA_USER],
"userName": format!("jane.smith@{SCIM_DOMAIN}"),
"name": {"givenName": "Jane", "familyName": "Smith"},
}),
)
.await;
assert_eq!(reply.json["displayName"], json!("Jane Smith"), "SCIM-24");
assert_eq!(
reply.json["name"]["formatted"],
json!("Jane Smith"),
"SCIM-24"
);
// SCIM-33: ignored attributes, never echoed; unknown ones refused
let reply = scim
.client
.put(
&format!("/Users/{jane}"),
json!({
"schemas": [SCHEMA_USER, SCHEMA_ENTERPRISE_USER],
"userName": format!("jane.smith@{SCIM_DOMAIN}"),
"password": "not stored anywhere",
"title": "Engineer",
"phoneNumbers": [{"value": "555-0100"}],
SCHEMA_ENTERPRISE_USER: {"department": "Sales"},
}),
)
.await;
reply.assert_status(200);
assert!(!reply.body.contains("not stored anywhere"), "SCIM-33");
assert!(!reply.body.contains("Engineer"), "SCIM-33");
for bad in [
json!({"schemas": [SCHEMA_USER], "userName": format!("jane.smith@{SCIM_DOMAIN}"), "dispalyName": "x"}),
json!({"schemas": ["urn:example:unknown"], "userName": format!("jane.smith@{SCIM_DOMAIN}")}),
json!({"userName": format!("jane.smith@{SCIM_DOMAIN}")}),
] {
scim.client
.put(&format!("/Users/{jane}"), bad)
.await
.assert_error(400, Some("invalidSyntax"));
}
// Test 19: active on an Inherit account leaves exactly Inherit
let id = Id::from_str(&jane).unwrap();
for active in [false, true] {
scim.client
.patch(
&format!("/Users/{jane}"),
patch_body(json!([{"op": "Replace", "value": {"active": if active { "True" } else { "False" }}}])),
)
.await
.assert_status(200);
let account = admin.registry_get::<structs::Account>(id).await;
let structs::Account::User(user) = account else {
panic!()
};
if active {
assert_eq!(user.permissions, Permissions::Inherit, "test 19");
} else {
assert_ne!(user.permissions, Permissions::Inherit, "test 19");
}
}
// ... and custom permissions come back as they were
let custom = Permissions::Merge(PermissionsList {
enabled_permissions: Map::new(vec![Permission::JmapEmailGet]),
disabled_permissions: Map::new(vec![Permission::JmapEmailQuery]),
});
admin
.registry_update_object(
ObjectType::Account,
id,
json!({ Property::Permissions: custom }),
)
.await;
for active in [false, true] {
scim.client
.patch(
&format!("/Users/{jane}"),
patch_body(json!([{"op": "replace", "path": "active", "value": active}])),
)
.await
.assert_status(200);
}
let structs::Account::User(user) = admin.registry_get::<structs::Account>(id).await else {
panic!()
};
assert_eq!(user.permissions, custom, "test 19");
// Test 20: externalId unique in a tenant, matched case-exactly
scim.client
.patch(
&format!("/Users/{jane}"),
patch_body(json!([{"op": "add", "path": "externalId", "value": "EXT-1"}])),
)
.await
.assert_status(200);
scim.client
.patch(
&format!("/Users/{other}"),
patch_body(json!([{"op": "add", "path": "externalId", "value": "EXT-1"}])),
)
.await
.assert_error(409, Some("uniqueness"));
let found = scim
.client
.get(&query("/Users", "externalId eq \"EXT-1\""))
.await;
assert_eq!(found.total_results(), 1, "test 20");
let found = scim
.client
.get(&query("/Users", "externalId eq \"ext-1\""))
.await;
assert_eq!(found.total_results(), 0, "test 20: case-exact");
scim.client
.patch(
&format!("/Users/{other}"),
patch_body(json!([{"op": "add", "path": "externalId", "value": ""}])),
)
.await
.assert_error(400, Some("invalidValue"));
// SCIM-28: groups is read-only
scim.client
.patch(
&format!("/Users/{jane}"),
patch_body(json!([{"op": "add", "path": "groups", "value": [{"value": "x"}]}])),
)
.await
.assert_error(400, Some("mutability"));
scim.destroy(&format!("/Users/{jane}")).await;
scim.destroy(&format!("/Users/{other}")).await;
}
/// Test 21 (SCIM-34 to SCIM-38).
async fn groups(scim: &ScimTest) {
let sales = scim.client.post("/Groups", group_body("Sales EMEA")).await;
sales.assert_status(201);
let sales_id = sales.id();
scim.client
.post("/Groups", group_body("sales emea"))
.await
.assert_error(409, Some("uniqueness"));
// SCIM-35: the address, and a second group taking the suffix
let mut lmtp = SmtpConnection::connect().await;
lmtp.mail_from("[email protected]", 2).await;
lmtp.rcpt_to(&format!("sales-emea@{SCIM_DOMAIN}"), 2).await;
lmtp.quit().await;
let renamed = scim
.client
.patch(
&format!("/Groups/{sales_id}"),
patch_body(json!([{"op": "replace", "path": "displayName", "value": "Sales Europe"}])),
)
.await;
renamed.assert_status(200);
let second = scim.create_group("Sales-EMEA").await;
let mut lmtp = SmtpConnection::connect().await;
lmtp.mail_from("[email protected]", 2).await;
lmtp.rcpt_to(&format!("sales-emea-2@{SCIM_DOMAIN}"), 2)
.await;
lmtp.quit().await;
// A group as member, an unknown member: 400
for member in [second.clone(), "zzzzzzzz".to_string()] {
scim.client
.patch(
&format!("/Groups/{sales_id}"),
patch_body(json!([{"op": "add", "path": "members", "value": [{"value": member}]}])),
)
.await
.assert_error(400, Some("invalidValue"));
}
// SCIM-38: the version changes when a member is added
let user = scim.create_user(&format!("member@{SCIM_DOMAIN}")).await;
let before = scim.client.get(&format!("/Groups/{sales_id}")).await;
let after = scim
.client
.patch(
&format!("/Groups/{sales_id}"),
patch_body(json!([{"op": "add", "path": "members", "value": [{"value": user}]}])),
)
.await;
after.assert_status(200);
assert_ne!(after.etag(), before.etag(), "SCIM-38");
assert_eq!(after.json["members"][0]["value"], json!(user), "SCIM-36");
assert_eq!(after.json["members"][0]["type"], json!("User"), "SCIM-36");
let member = scim.client.get(&format!("/Users/{user}")).await;
assert_eq!(
member.json["groups"][0]["value"],
json!(sales_id),
"SCIM-28"
);
assert_eq!(
member.json["groups"][0]["display"],
json!("Sales Europe"),
"SCIM-28"
);
// Test 22: removing a non-member succeeds
scim.client
.patch(
&format!("/Groups/{sales_id}"),
patch_body(json!([{"op": "remove", "path": "members[value eq \"zzzzzzzz\"]"}])),
)
.await
.assert_status(200);
// SCIM-36: remove without a filter empties the group
let emptied = scim
.client
.patch(
&format!("/Groups/{sales_id}"),
patch_body(json!([{"op": "remove", "path": "members"}])),
)
.await;
assert_eq!(emptied.json["members"], json!([]), "SCIM-36");
// SCIM-37: over 200 members needs excludedAttributes=members
let mut members = Vec::new();
let mut operations = Vec::new();
for n in 0..201 {
operations.push(json!({
"method": "POST",
"path": "/Users",
"bulkId": format!("m{n}"),
"data": user_body(&format!("crowd{n}@{SCIM_DOMAIN}")),
}));
}
let created = scim
.client
.post(
"/Bulk",
json!({"schemas": [MESSAGE_BULK_REQUEST], "Operations": operations}),
)
.await;
created.assert_status(200);
for result in created.json["Operations"].as_array().unwrap() {
assert_eq!(result["status"], json!("201"), "{result}");
let id = result["location"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string();
members.push(json!({"value": id}));
}
scim.client
.patch(
&format!("/Groups/{sales_id}"),
patch_body(json!([{"op": "add", "path": "members", "value": members}])),
)
.await
.assert_status(200);
scim.client
.get(&format!("/Groups/{sales_id}"))
.await
.assert_error(400, Some("tooMany"));
scim.client
.get(&format!("/Groups/{sales_id}?excludedAttributes=members"))
.await
.assert_status(200);
// Test 25: count is capped at 200; count=0 gives totals only
let page = scim.client.get("/Users?count=500").await;
assert_eq!(page.json["itemsPerPage"], json!(200), "test 25");
assert!(page.total_results() > 200, "test 25");
let totals = scim.client.get("/Users?count=0").await;
assert!(totals.json.get("Resources").is_none(), "test 25");
assert_eq!(totals.total_results(), page.total_results(), "test 25");
let a = scim.client.get("/Users?startIndex=11&count=10").await;
let b = scim.client.get("/Users?startIndex=11&count=10").await;
assert_eq!(a.resource_ids(), b.resource_ids(), "test 25: stable");
let mut ids = page.resource_ids();
let sorted = {
let mut s = ids.clone();
s.sort_by_key(|id| Id::from_str(id).unwrap().id());
s
};
assert_eq!(ids, sorted, "SCIM-47");
let descending = scim
.client
.get("/Users?count=200&sortBy=userName&sortOrder=descending")
.await;
descending.assert_status(200);
scim.client
.get("/Users?sortBy=title")
.await
.assert_error(400, Some("invalidValue"));
// Test 26: a cursor walk, and cursors that don't match
let mut seen = Vec::new();
let mut cursor = String::new();
loop {
let page = scim
.client
.get(&format!("/Users?count=100&cursor={cursor}"))
.await;
page.assert_status(200);
seen.extend(page.resource_ids());
match page.json["nextCursor"].as_str() {
Some(next) => cursor = next.to_string(),
None => break,
}
}
ids.sort();
seen.sort();
seen.dedup();
assert_eq!(seen.len() as u64, page.total_results(), "test 26");
let first = scim.client.get("/Users?count=100&cursor=").await;
let next = first.json["nextCursor"].as_str().unwrap().to_string();
scim.client
.get(&format!("/Users?count=50&cursor={next}"))
.await
.assert_error(400, Some("invalidCount"));
scim.client
.get(&format!(
"{}&count=100&cursor={next}",
query("/Users", "externalId eq \"nobody\"")
))
.await
.assert_error(400, Some("invalidCursor"));
let mut tampered = next.clone().into_bytes();
tampered[4] = if tampered[4] == b'A' { b'B' } else { b'A' };
scim.client
.get(&format!(
"/Users?count=100&cursor={}",
String::from_utf8(tampered).unwrap()
))
.await
.assert_error(400, Some("invalidCursor"));
scim.client
.get("/Users?cursor=&startIndex=1")
.await
.assert_error(400, Some("invalidValue"));
// Test 24: an unindexed filter over 200 candidates
scim.client
.get(&query("/Users", "active eq false"))
.await
.assert_error(400, Some("tooMany"));
// Clean up
let mut operations = vec![];
for member in &members {
operations.push(json!({
"method": "DELETE",
"path": format!("/Users/{}", member["value"].as_str().unwrap()),
}));
}
scim.client
.post(
"/Bulk",
json!({"schemas": [MESSAGE_BULK_REQUEST], "Operations": operations}),
)
.await
.assert_status(200);
scim.destroy(&format!("/Groups/{sales_id}")).await;
scim.destroy(&format!("/Groups/{second}")).await;
scim.destroy(&format!("/Users/{user}")).await;
}
/// Test 22 (SCIM-42).
async fn patching(scim: &ScimTest) {
let id = scim.create_user(&format!("patchy@{SCIM_DOMAIN}")).await;
let before = scim.client.get(&format!("/Users/{id}")).await;
scim.client
.patch(
&format!("/Users/{id}"),
patch_body(json!([
{"op": "replace", "path": "displayName", "value": "Changed"},
{"op": "replace", "path": "nothing.here", "value": "x"},
])),
)
.await
.assert_error(400, Some("invalidPath"));
let after = scim.client.get(&format!("/Users/{id}")).await;
assert_eq!(after.etag(), before.etag(), "test 22: nothing changed");
// Entra's shape: Replace, a sub-attribute, an extension path
let reply = scim
.client
.patch(
&format!("/Users/{id}"),
patch_body(json!([
{"op": "Replace", "path": "name.givenName", "value": "Pat"},
{"op": "Replace", "path": "displayName", "value": "Pat Chy"},
{"op": "Add", "path": format!("{SCHEMA_ENTERPRISE_USER}:department"), "value": "Ops"},
])),
)
.await;
reply.assert_status(200);
assert_eq!(reply.json["displayName"], json!("Pat Chy"), "SCIM-42");
scim.client
.patch(
&format!("/Users/{id}"),
patch_body(json!([{"op": "remove"}])),
)
.await
.assert_error(400, None);
scim.destroy(&format!("/Users/{id}")).await;
}
/// Test 23 (SCIM-44).
async fn conditional(scim: &ScimTest) {
let id = scim.create_user(&format!("etag@{SCIM_DOMAIN}")).await;
let path = format!("/Users/{id}");
let current = scim.client.get(&path).await;
let etag = current.etag().unwrap();
let not_modified = scim
.client
.request(
reqwest::Method::GET,
&path,
None,
&[("if-none-match", &etag)],
)
.await;
assert_eq!(not_modified.status, 304, "test 23");
scim.client
.patch(
&path,
patch_body(json!([{"op": "replace", "path": "displayName", "value": "Moved on"}])),
)
.await
.assert_status(200);
for (method, body) in [
(
reqwest::Method::PUT,
Some(user_body(&format!("etag@{SCIM_DOMAIN}"))),
),
(
reqwest::Method::PATCH,
Some(patch_body(
json!([{"op": "replace", "path": "displayName", "value": "x"}]),
)),
),
(reqwest::Method::DELETE, None),
] {
scim.client
.request(method, &path, body, &[("if-match", &etag)])
.await
.assert_error(412, None);
}
scim.destroy(&path).await;
}
/// Test 24 (SCIM-45, SCIM-46, SCIM-50).
async fn queries(scim: &ScimTest) {
let id = scim
.client
.post(
"/Users",
json!({
"schemas": [SCHEMA_USER],
"userName": format!("findme@{SCIM_DOMAIN}"),
"displayName": "Find Me",
"externalId": "FIND-1",
"emails": [{"value": format!("found@{SCIM_DOMAIN}")}],
}),
)
.await
.assert_status(201)
.id();
let group = scim
.client
.post(
"/Groups",
json!({"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Finders", "members": [{"value": id}]}),
)
.await
.assert_status(201)
.id();
for filter in [
format!("id eq \"{id}\""),
"externalId eq \"FIND-1\"".to_string(),
format!("userName eq \"FINDME@{SCIM_DOMAIN}\""),
format!("emails eq \"found@{SCIM_DOMAIN}\""),
format!("emails.value eq \"findme@{SCIM_DOMAIN}\""),
format!("groups eq \"{group}\""),
format!("groups.value eq \"{group}\" and displayName eq \"find me\""),
format!("userName eq \"findme@{SCIM_DOMAIN}\" and active eq true"),
format!("userName eq \"findme@{SCIM_DOMAIN}\" and name.formatted eq \"Find Me\""),
] {
let reply = scim.client.get(&query("/Users", &filter)).await;
reply.assert_status(200);
assert_eq!(reply.total_results(), 1, "{filter}: {}", reply.body);
reply.assert_contains_id(&id);
}
for filter in [
format!("members eq \"{id}\""),
"displayName eq \"finders\"".to_string(),
] {
let reply = scim.client.get(&query("/Groups", &filter)).await;
assert_eq!(reply.total_results(), 1, "{filter}: {}", reply.body);
}
let empty = scim
.client
.get(&query("/Users", "userName eq \"[email protected]\""))
.await;
empty.assert_status(200);
assert_eq!(empty.total_results(), 0, "SCIM-45: an empty ListResponse");
for filter in [
"userName co \"find\"",
"userName eq \"a\" or userName eq \"b\"",
"title pr",
"emails[type eq \"work\"]",
"title eq \"x\"",
] {
scim.client
.get(&query("/Users", filter))
.await
.assert_error(400, Some("invalidFilter"));
}
let searched = scim
.client
.post(
"/Users/.search",
json!({"schemas": [MESSAGE_SEARCH_REQUEST], "filter": "externalId eq \"FIND-1\"", "attributes": ["userName"]}),
)
.await;
assert_eq!(searched.total_results(), 1, "SCIM-50");
assert!(
searched.json["Resources"][0].get("displayName").is_none(),
"SCIM-40"
);
let both = scim
.client
.post(
"/.search",
json!({"schemas": [MESSAGE_SEARCH_REQUEST], "count": 200}),
)
.await;
both.assert_status(200);
let kinds = both.json["Resources"]
.as_array()
.unwrap()
.iter()
.map(|r| r["schemas"][0].as_str().unwrap().to_string())
.collect::<Vec<_>>();
let first_group = kinds.iter().position(|k| k.ends_with("Group")).unwrap();
assert!(
kinds[first_group..].iter().all(|k| k.ends_with("Group")),
"SCIM-50: users first"
);
scim.destroy(&format!("/Groups/{group}")).await;
scim.destroy(&format!("/Users/{id}")).await;
}
/// Test 27 (SCIM-51).
async fn bulk(scim: &ScimTest) {
let reply = scim
.client
.post(
"/Bulk",
json!({
"schemas": [MESSAGE_BULK_REQUEST],
"Operations": [
{"method": "POST", "path": "/Users", "bulkId": "u1", "data": user_body(&format!("bulk1@{SCIM_DOMAIN}"))},
{"method": "POST", "path": "/Groups", "bulkId": "g1", "data": {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "Bulk Team",
"members": [{"value": "bulkId:u1"}],
}},
{"method": "PATCH", "path": "/Users/bulkId:u1", "data": patch_body(json!([{"op": "replace", "path": "displayName", "value": "Bulk One"}]))},
{"method": "DELETE", "path": "/Users/bulkId:nope"},
],
}),
)
.await;
reply.assert_status(200);
let results = reply.json["Operations"].as_array().unwrap();
assert_eq!(results[0]["status"], json!("201"), "{results:?}");
assert_eq!(results[1]["status"], json!("201"), "{results:?}");
assert_eq!(results[2]["status"], json!("200"), "{results:?}");
assert_eq!(results[3]["status"], json!("409"), "{results:?}");
assert!(results[0]["version"].is_string(), "SCIM-51");
let user = results[0]["location"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string();
let group = results[1]["location"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string();
let member = scim.client.get(&format!("/Users/{user}")).await;
assert_eq!(member.json["groups"][0]["value"], json!(group), "SCIM-51");
assert_eq!(member.json["displayName"], json!("Bulk One"), "SCIM-51");
// failOnErrors stops after the first failure
let reply = scim
.client
.post(
"/Bulk",
json!({
"schemas": [MESSAGE_BULK_REQUEST],
"failOnErrors": 1,
"Operations": [
{"method": "POST", "path": "/Users", "bulkId": "x", "data": user_body("bad address")},
{"method": "POST", "path": "/Users", "bulkId": "y", "data": user_body(&format!("never@{SCIM_DOMAIN}"))},
],
}),
)
.await;
assert_eq!(
reply.json["Operations"].as_array().unwrap().len(),
1,
"test 27"
);
// 1001 operations: 413
let operations = (0..1001)
.map(|_| json!({"method": "DELETE", "path": "/Users/x"}))
.collect::<Vec<_>>();
let reply = scim
.client
.post(
"/Bulk",
json!({"schemas": [MESSAGE_BULK_REQUEST], "Operations": operations}),
)
.await;
assert_eq!(reply.status, 413, "test 27");
scim.destroy(&format!("/Groups/{group}")).await;
scim.destroy(&format!("/Users/{user}")).await;
}
/// An account made by an administrator, with a password.
async fn manual_user(test: &TestServer, scim: &ScimTest, name: &str) -> Id {
test.account("admin")
.registry_create_object(structs::Account::User(UserAccount {
name: name.to_string(),
domain_id: scim.domain_id,
credentials: List::from_iter([structs::Credential::Password(PasswordCredential {
secret: USER_SECRET.to_string(),
..Default::default()
})]),
..Default::default()
}))
.await
}
/// Whether an IMAP LOGIN is accepted. A refusal may be a tagged `NO` or
/// the server closing the connection.
async fn imap_login(address: &str, ok: bool) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let stream = tokio::net::TcpStream::connect("127.0.0.1:9991")
.await
.unwrap();
let (reader, mut writer) = tokio::io::split(stream);
let mut lines = BufReader::new(reader).lines();
let greeting = lines.next_line().await.unwrap().unwrap_or_default();
assert!(greeting.starts_with("* OK"), "{greeting}");
writer
.write_all(format!("a LOGIN \"{address}\" \"{USER_SECRET}\"\r\n").as_bytes())
.await
.unwrap();
let mut accepted = false;
while let Ok(Ok(Some(line))) =
tokio::time::timeout(std::time::Duration::from_secs(10), lines.next_line()).await
{
if let Some(status) = line.strip_prefix("a ") {
accepted = status.starts_with("OK");
break;
}
}
assert_eq!(accepted, ok, "IMAP login of {address}");
}
type IdleLines = tokio::io::Lines<tokio::io::BufReader<tokio::io::ReadHalf<tokio::net::TcpStream>>>;
/// An IMAP session in IDLE.
async fn idle_session(address: &str) -> (IdleLines, tokio::io::WriteHalf<tokio::net::TcpStream>) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let stream = tokio::net::TcpStream::connect("127.0.0.1:9991")
.await
.unwrap();
let (reader, mut writer) = tokio::io::split(stream);
let mut lines = BufReader::new(reader).lines();
lines.next_line().await.unwrap();
for (tag, command) in [
("a", format!("LOGIN \"{address}\" \"{USER_SECRET}\"")),
("b", "SELECT INBOX".to_string()),
] {
writer
.write_all(format!("{tag} {command}\r\n").as_bytes())
.await
.unwrap();
loop {
let line = lines.next_line().await.unwrap().unwrap();
if let Some(status) = line.strip_prefix(&format!("{tag} ")) {
assert!(status.starts_with("OK"), "{command}: {line}");
break;
}
}
}
writer.write_all(b"c IDLE\r\n").await.unwrap();
let line = lines.next_line().await.unwrap().unwrap();
assert!(line.starts_with('+'), "IDLE: {line}");
(lines, writer)
}
/// Whether the server ends an IDLE session within ten seconds.
async fn idle_ends(idle: &mut (IdleLines, tokio::io::WriteHalf<tokio::net::TcpStream>)) -> bool {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match tokio::time::timeout_at(deadline, idle.0.next_line()).await {
Ok(Ok(Some(line))) if line.starts_with("* BYE") => return true,
Ok(Ok(Some(_))) => continue,
Ok(Ok(None)) | Ok(Err(_)) => return true,
Err(_) => return false,
}
}
}
/// Test 28 (SCIM-52): suspension stops sign-in, not mail.
async fn suspension(test: &TestServer, scim: &ScimTest) {
let address = format!("suspended@{SCIM_DOMAIN}");
let id = manual_user(test, scim, "suspended").await;
imap_login(&address, true).await;
// Credentials used, and cached, before the suspension
let user = Account::new("[email protected]", USER_SECRET, &[], "", id);
let (_, user_key) = api_key_with_id(&user, json!({"@type": "Inherit"})).await;
let basic = format!(
"Basic {}",
base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
format!("{address}:{USER_SECRET}")
)
);
let bearer = format!("Bearer {user_key}");
for authorization in [&basic, &bearer] {
assert_eq!(
crate::scim::jmap_session_status(authorization).await,
200,
"test 28"
);
}
let mut idle = idle_session(&address).await;
scim.client
.patch(
&format!("/Users/{id}"),
patch_body(json!([{"op": "replace", "path": "active", "value": false}])),
)
.await
.assert_status(200);
imap_login(&address, false).await;
assert!(idle_ends(&mut idle).await, "test 28: an open IDLE is ended");
for authorization in [&basic, &bearer] {
let status = crate::scim::jmap_session_status(authorization).await;
assert!(matches!(status, 401 | 403), "test 28: {status}");
}
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&[&address],
"From: [email protected]\r\nSubject: still arrives\r\n\r\nHello\r\n",
)
.await;
scim.client
.patch(
&format!("/Users/{id}"),
patch_body(json!([{"op": "replace", "path": "active", "value": true}])),
)
.await
.assert_status(200);
imap_login(&address, true).await;
scim.destroy(&format!("/Users/{id}")).await;
}
/// Test 29 (SCIM-52): deletion, and a held address.
async fn deletion(test: &TestServer, scim: &ScimTest) {
let admin = test.account("admin");
let address = format!("gone@{SCIM_DOMAIN}");
let id = scim.create_user(&address).await;
scim.client
.delete(&format!("/Users/{id}"))
.await
.assert_status(204);
scim.client
.get(&format!("/Users/{id}"))
.await
.assert_error(404, None);
let mut lmtp = SmtpConnection::connect().await;
lmtp.mail_from("[email protected]", 2).await;
lmtp.rcpt_to(&address, 5).await;
lmtp.quit().await;
// With accounts held after deletion, the address stays reserved
admin
.registry_update_setting(
DataRetention {
archive_deleted_accounts_for: Some(Duration::from_millis(86_400_000)),
..Default::default()
},
&[Property::ArchiveDeletedAccountsFor],
)
.await;
let held = format!("held@{SCIM_DOMAIN}");
let id = scim.create_user(&held).await;
scim.client
.delete(&format!("/Users/{id}"))
.await
.assert_status(204);
scim.client
.post("/Users", user_body(&held))
.await
.assert_error(409, Some("uniqueness"));
admin
.registry_update_setting(
DataRetention {
archive_deleted_accounts_for: None,
..Default::default()
},
&[Property::ArchiveDeletedAccountsFor],
)
.await;
}
/// Test 30 (SCIM-32, SCIM-55, SCIM-56): an administrator's account is
/// found, adopted, and keeps its local settings.
async fn adoption(test: &TestServer, scim: &ScimTest) {
let admin = test.account("admin");
let id = manual_user(test, scim, "handmade").await;
admin
.registry_update_object(
ObjectType::Account,
id,
json!({ Property::Roles: {"@type": "Admin"} }),
)
.await;
let found = scim
.client
.get(&query(
"/Users",
&format!("userName eq \"handmade@{SCIM_DOMAIN}\""),
))
.await;
assert_eq!(found.total_results(), 1, "test 30");
scim.client
.post("/Users", user_body(&format!("handmade@{SCIM_DOMAIN}")))
.await
.assert_error(409, Some("uniqueness"));
scim.client
.patch(
&format!("/Users/{id}"),
patch_body(json!([{"op": "add", "path": "externalId", "value": "IDP-9"}])),
)
.await
.assert_status(200);
let structs::Account::User(user) = admin.registry_get::<structs::Account>(id).await else {
panic!()
};
assert_eq!(user.external_id.as_deref(), Some("IDP-9"), "test 30");
assert_eq!(user.roles, structs::UserRoles::Admin, "SCIM-32");
assert_eq!(user.credentials.len(), 1, "SCIM-32");
imap_login(&format!("handmade@{SCIM_DOMAIN}"), true).await;
scim.destroy(&format!("/Users/{id}")).await;
}
/// Tests 9 and 14 (SCIM-12, SCIM-20).
async fn tenants(test: &TestServer, scim: &ScimTest) {
let admin = test.account("admin");
let _ = scim;
for (name, allows_scim) in [("nosync", false), ("capped", true)] {
let tenant = admin
.registry_create_object(Tenant {
name: name.to_string(),
permissions: Permissions::Merge(PermissionsList {
disabled_permissions: if allows_scim {
Default::default()
} else {
Map::new(vec![Permission::ScimAccess])
},
enabled_permissions: if allows_scim {
Map::new(vec![Permission::ScimAccess, Permission::UnlimitedRequests])
} else {
Default::default()
},
}),
..Default::default()
})
.await;
let domain_name = format!("{name}.example.com");
let domain = admin
.registry_create_object(Domain {
is_enabled: true,
name: domain_name.clone(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
member_tenant_id: Some(tenant),
allow_scim_provisioning: true,
..Default::default()
})
.await;
let principal_id =
create_principal(admin, "svc", domain, Some(tenant), full_permissions()).await;
admin.registry_create_object(Action::InvalidateCaches).await;
let principal = Account::new(
Box::leak(format!("svc@{domain_name}").into_boxed_str()),
PRINCIPAL_SECRET,
&[],
"",
principal_id,
);
let client =
ScimClient::bearer(&api_key(admin, &principal, json!({"@type": "Inherit"})).await);
if !allows_scim {
// Test 9: the tenant's ceiling wins
client
.get("/Users")
.await
.assert_error(403, None)
.assert_detail_contains("scimAccess");
} else {
// Test 14: maxAccounts reached
admin
.registry_update_object(
ObjectType::Tenant,
tenant,
json!({ Property::Quotas: VecMap::from_iter([(TenantStorageQuota::MaxAccounts, 1u64)]) }),
)
.await;
let events = Collector::read_metric(MetricType::LimitTenantQuota);
client
.post("/Users", user_body(&format!("extra@{domain_name}")))
.await
.assert_error(403, None)
.assert_detail_contains("maxAccounts");
assert!(
Collector::read_metric(MetricType::LimitTenantQuota) > events,
"test 14: limit.tenant-quota"
);
}
admin
.registry_destroy(ObjectType::Account, [principal_id])
.await;
admin.registry_destroy(ObjectType::Domain, [domain]).await;
admin.registry_destroy(ObjectType::Tenant, [tenant]).await;
}
admin.registry_create_object(Action::InvalidateCaches).await;
}
/// SCIM-58 to SCIM-60, through just-in-time sync itself (acceptance test
/// 5 does the same over OIDC once per-domain directories exist).
async fn authority(test: &TestServer, scim: &ScimTest, closed_id: Id) {
let admin = test.account("admin");
let sync = |email: &str, name: &str, groups: Vec<String>| directory::Account {
email: email.to_string(),
email_aliases: vec![],
secret: None,
groups: Some(groups),
description: Some(name.to_string()),
};
// An unprovisioned person on a SCIM domain: refused, nothing created
assert!(
test.server
.synchronize_account(sync(&format!("jit@{SCIM_DOMAIN}"), "JIT", vec![]))
.await
.is_err(),
"SCIM-58"
);
scim.client
.get(&query(
"/Users",
&format!("userName eq \"jit@{SCIM_DOMAIN}\""),
))
.await
.assert_status(200);
assert_eq!(
scim.client
.get(&query(
"/Users",
&format!("userName eq \"jit@{SCIM_DOMAIN}\"")
))
.await
.total_results(),
0,
"SCIM-58: no account"
);
// A provisioned one: sign-in changes nothing, creates no group
let id = scim
.client
.post(
"/Users",
json!({"schemas": [SCHEMA_USER], "userName": format!("synced@{SCIM_DOMAIN}"), "displayName": "From SCIM"}),
)
.await
.assert_status(201)
.id();
let before = scim.client.get(&format!("/Users/{id}")).await;
for _ in 0..2 {
test.server
.synchronize_account(sync(
&format!("synced@{SCIM_DOMAIN}"),
"From the directory",
vec![format!("claimed@{SCIM_DOMAIN}")],
))
.await
.unwrap();
}
let after = scim.client.get(&format!("/Users/{id}")).await;
assert_eq!(after.json["displayName"], json!("From SCIM"), "SCIM-58");
assert_eq!(after.etag(), before.etag(), "SCIM-58: version unchanged");
assert_eq!(
scim.client
.get(&query("/Groups", "displayName eq \"claimed\""))
.await
.total_results(),
0,
"SCIM-58: no group from the claim"
);
// SCIM-59: without the flag, sync works as it always has
let made = test
.server
.synchronize_account(sync(&format!("jit@{CLOSED}"), "JIT", vec![]))
.await
.unwrap();
admin
.registry_destroy(ObjectType::Account, [Id::from(made.id)])
.await;
// SCIM-60: turning the flag off hands the account back to sync
admin
.registry_update_object(
ObjectType::Domain,
scim.domain_id,
json!({ Property::AllowScimProvisioning: false }),
)
.await;
test.server
.synchronize_account(sync(
&format!("synced@{SCIM_DOMAIN}"),
"From the directory",
vec![],
))
.await
.unwrap();
let structs::Account::User(user) = admin
.registry_get::<structs::Account>(Id::from_str(&id).unwrap())
.await
else {
panic!()
};
assert_eq!(
user.description.as_deref(),
Some("From the directory"),
"SCIM-60"
);
scim.client
.get(&format!("/Users/{id}"))
.await
.assert_error(404, None);
admin
.registry_update_object(
ObjectType::Domain,
scim.domain_id,
json!({ Property::AllowScimProvisioning: true }),
)
.await;
scim.destroy(&format!("/Users/{id}")).await;
let _ = closed_id;
}
/// SCIM-14: the authenticated rate limit, per principal, with
/// `unlimitedRequests` exempt.
async fn rate_limits(test: &TestServer, scim: &ScimTest) {
let admin = test.account("admin");
admin
.registry_update_setting(
structs::Http {
rate_limit_authenticated: Some(structs::Rate {
count: 2,
period: Duration::from_millis(60_000),
}),
..Default::default()
},
&[Property::RateLimitAuthenticated],
)
.await;
admin.reload_settings().await;
let principal = Account::new(
"[email protected]",
PRINCIPAL_SECRET,
&[],
"",
scim.principal_id,
);
let limited = ScimClient::bearer(
&api_key(
admin,
&principal,
json!({"@type": "Disable", "permissions": {"unlimitedRequests": true}}),
)
.await,
);
let mut refused = None;
for _ in 0..6 {
let reply = limited.get("/Users?count=1").await;
if reply.status == 429 {
refused = Some(reply);
break;
}
reply.assert_status(200);
}
let refused = refused.expect("SCIM-14: never rate limited");
refused.assert_error(429, None);
assert!(
refused.header("retry-after").is_some(),
"SCIM-14: Retry-After"
);
scim.client.get("/Users?count=1").await.assert_status(200);
admin
.registry_update_setting(
structs::Http {
rate_limit_authenticated: None,
..Default::default()
},
&[Property::RateLimitAuthenticated],
)
.await;
admin.reload_settings().await;
}