Registry testing - part 4
This commit is contained in:
198
tests/src/system/authentication.rs
Normal file
198
tests/src/system/authentication.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use jmap_proto::error::set::SetErrorType;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::CredentialType,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{Account, Credential, PasswordCredential, SecondaryCredential, UserAccount},
|
||||
},
|
||||
types::{EnumImpl, list::List},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
let admin = test.account("admin@example.org");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
|
||||
// Weak passwords should be rejected
|
||||
admin
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
name: "user".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "12345".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidProperties)
|
||||
.assert_description_contains("Password must be at least 8 characters long.");
|
||||
admin
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
name: "user".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "12345678".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidProperties)
|
||||
.assert_description_contains(concat!(
|
||||
"Password is too weak. This is a top-10 common password. ",
|
||||
"Add another word or two. Uncommon words are better."
|
||||
));
|
||||
|
||||
// Adding secondary credentials should not be allowed
|
||||
admin
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
name: "user".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::AppPassword(SecondaryCredential {
|
||||
description: "Test app password".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidProperties)
|
||||
.assert_description_contains("Secondary credentials cannot be set directly");
|
||||
admin
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
name: "user".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::ApiKey(SecondaryCredential {
|
||||
description: "Test API key".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidProperties)
|
||||
.assert_description_contains("Secondary credentials cannot be set directly");
|
||||
|
||||
// Creating a user with a valid password should succeed
|
||||
let user_id = admin
|
||||
.registry_create_object(Account::User(UserAccount {
|
||||
name: "user".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "this is a very strong password".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
validate_password("user@example.org", "this is a very strong password", true).await;
|
||||
validate_password("user@example.org", "wrong password", false).await;
|
||||
|
||||
// Change password as admin
|
||||
admin
|
||||
.registry_update_object_expect_err(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
"credentials/0/secret": "12345"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidProperties)
|
||||
.assert_description_contains("Password must be at least 8 characters long.");
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
"credentials/0/secret": "very strong password indeed"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
validate_password("user@example.org", "this is a very strong password", false).await;
|
||||
validate_password("user@example.org", "very strong password indeed", true).await;
|
||||
|
||||
// Change password as user
|
||||
let user = crate::utils::account::Account::new(
|
||||
"user@example.org",
|
||||
"very strong password indeed",
|
||||
&[],
|
||||
user_id,
|
||||
)
|
||||
.await;
|
||||
let credential_id = user
|
||||
.registry_query(
|
||||
ObjectType::Credential,
|
||||
[(Property::Type, CredentialType::Password.as_str())],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await[0];
|
||||
|
||||
// Password updates should require the old password
|
||||
user.registry_update_object_expect_err(
|
||||
ObjectType::Credential,
|
||||
credential_id,
|
||||
json!({
|
||||
Property::Secret: "12345"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::Forbidden)
|
||||
.assert_description_contains(
|
||||
"Current secret must be provided to change the password or OTP auth.",
|
||||
);
|
||||
|
||||
user.registry_query(
|
||||
ObjectType::Credential,
|
||||
[(Property::Type, CredentialType::Password.as_str())],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await[0];
|
||||
|
||||
// Password policies should be enforced when changing password
|
||||
/*user.registry_update_object_expect_err(
|
||||
ObjectType::Credential,
|
||||
credential_id,
|
||||
json!({
|
||||
Property::CurrentSecret: "very strong password indeed",
|
||||
Property::Secret: "12345"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidProperties)
|
||||
.assert_description_contains("Password must be at least 8 characters long.");*/
|
||||
}
|
||||
|
||||
pub async fn validate_password(username: &str, password: &str, is_valid: bool) {
|
||||
let response = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.unwrap()
|
||||
.get("https://127.0.0.1:8899/.well-known/jmap")
|
||||
.basic_auth(username, Some(password))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() != is_valid {
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
panic!(
|
||||
"Expected password to be {}. Server responded with status {}: {}",
|
||||
if is_valid { "valid" } else { "invalid" },
|
||||
status,
|
||||
text
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod authentication;
|
||||
pub mod directory;
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -26,8 +28,9 @@ pub async fn system_tests() {
|
||||
)
|
||||
.await;
|
||||
test.account("admin")
|
||||
.assign_role_to_account(admin_id, "superuser")
|
||||
.assign_roles_to_account(admin_id, &["user", "superuser"])
|
||||
.await;
|
||||
|
||||
directory::test(&test).await;
|
||||
//directory::test(&test).await;
|
||||
authentication::test(&test).await;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,12 @@ use jmap_client::client::{Client, Credentials};
|
||||
use registry::{
|
||||
schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{self, Credential, Domain, EmailAlias, PasswordCredential, UserAccount},
|
||||
structs::{
|
||||
self, Credential, CustomRoles, Domain, EmailAlias, PasswordCredential, Roles,
|
||||
UserAccount,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
types::{list::List, map::Map},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
@@ -144,9 +147,8 @@ impl Account {
|
||||
[(Property::Name, name)],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
.await;
|
||||
|
||||
match ids.len() {
|
||||
0 => self.create_domain(name).await,
|
||||
1 => ids[0],
|
||||
@@ -163,30 +165,32 @@ impl Account {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn assign_role_to_account(&self, account_id: Id, name: &str) {
|
||||
let role_id = self
|
||||
.registry_query(
|
||||
ObjectType::Role,
|
||||
[(Property::Description, name)],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("Role {name} not found"));
|
||||
pub async fn assign_roles_to_account(&self, account_id: Id, names: &[&str]) {
|
||||
let mut role_ids = Vec::new();
|
||||
for name in names {
|
||||
let role_id = *self
|
||||
.registry_query(
|
||||
ObjectType::Role,
|
||||
[(Property::Description, *name)],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
.first()
|
||||
.unwrap_or_else(|| panic!("Role {name} not found"));
|
||||
role_ids.push(role_id);
|
||||
}
|
||||
|
||||
self.registry_update(
|
||||
ObjectType::Account,
|
||||
[(
|
||||
account_id,
|
||||
json!({
|
||||
"roleIds": {
|
||||
role_id: true
|
||||
}
|
||||
Property::Roles: Roles::Custom(CustomRoles { role_ids: Map::new(role_ids) })
|
||||
}),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.updated_id(account_id);
|
||||
}
|
||||
|
||||
pub async fn client_owned(&self) -> Client {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
use crate::utils::account::Account;
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use hyper::header;
|
||||
use jmap_proto::error::set::SetErrorType;
|
||||
use registry::types::error::ValidationError;
|
||||
use registry::types::id::ObjectId;
|
||||
use serde_json::{Value, json};
|
||||
use std::{fmt::Display, str::FromStr, time::Duration};
|
||||
use types::id::Id;
|
||||
@@ -427,6 +430,10 @@ impl JmapResponse {
|
||||
.unwrap_or_else(|| panic!("Missing updated item {id}: {self:?}"))
|
||||
}
|
||||
|
||||
pub fn updated_id(&self, id: Id) -> &Value {
|
||||
self.updated(&id.to_string())
|
||||
}
|
||||
|
||||
pub fn not_updated(&self, id: &str) -> &Value {
|
||||
self.0
|
||||
.pointer(&format!("/methodResponses/0/1/notUpdated/{id}"))
|
||||
@@ -491,6 +498,12 @@ impl JmapResponse {
|
||||
.map(|v| v.as_str().unwrap())
|
||||
}
|
||||
|
||||
pub fn destroyed_ids(&self) -> impl Iterator<Item = Id> {
|
||||
self.destroyed().map(move |id| {
|
||||
Id::from_str(id).unwrap_or_else(|_| panic!("Invalid id {id} in response: {self:?}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn not_destroyed(&self, id: &str) -> &Value {
|
||||
self.0
|
||||
.pointer(&format!("/methodResponses/0/1/notDestroyed/{id}"))
|
||||
@@ -536,6 +549,68 @@ impl JmapResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct JmapSetError {
|
||||
#[serde(rename = "type")]
|
||||
pub type_: SetErrorType,
|
||||
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub properties: Option<Vec<String>>,
|
||||
|
||||
#[serde(rename = "existingId")]
|
||||
#[serde(default)]
|
||||
pub existing_id: Option<Id>,
|
||||
|
||||
#[serde(rename = "objectId")]
|
||||
#[serde(default)]
|
||||
pub object_id: Option<ObjectId>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(rename = "linkedObjects")]
|
||||
pub linked_objects: Vec<ObjectId>,
|
||||
|
||||
#[serde(default)]
|
||||
#[serde(rename = "validationErrors")]
|
||||
pub validation_errors: Vec<ValidationError>,
|
||||
}
|
||||
|
||||
impl JmapSetError {
|
||||
pub fn assert_type(&self, expected: SetErrorType) -> &Self {
|
||||
if self.type_ != expected {
|
||||
panic!("Expected error type {expected:?} but got {self:?}");
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn assert_description_contains(&self, expected: &str) -> &Self {
|
||||
if let Some(description) = &self.description {
|
||||
if !description.contains(expected) {
|
||||
panic!("Expected error description to contain {expected} but got {description}");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected error description to contain {expected} but got no description");
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn assert_properties(&self, expected: &[&str]) -> &Self {
|
||||
let properties = self.properties.as_ref().unwrap_or_else(|| {
|
||||
panic!("Expected error to have properties {expected:?} but got no properties: {self:?}")
|
||||
});
|
||||
for expected in expected {
|
||||
if !properties.contains(&expected.to_string()) {
|
||||
panic!(
|
||||
"Expected error to have property {expected} but got properties {properties:?}: {self:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait JmapUtils {
|
||||
fn id(&self) -> &str {
|
||||
self.text_field("id")
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{account::Account, jmap::JmapResponse};
|
||||
use crate::utils::{
|
||||
account::Account,
|
||||
jmap::{JmapResponse, JmapSetError},
|
||||
};
|
||||
use registry::{
|
||||
schema::prelude::ObjectType,
|
||||
types::{EnumImpl, ObjectImpl},
|
||||
@@ -27,9 +30,7 @@ impl Account {
|
||||
items.into_iter().map(|item| {
|
||||
let mut item =
|
||||
serde_json::to_value(item).expect("Failed to serialize item to JSON");
|
||||
item.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|k, _| !["createdAt", "credentialId"].contains(&k.as_str()));
|
||||
remove_server_set_props(&mut item);
|
||||
item
|
||||
}),
|
||||
Vec::<(&str, &str)>::new(),
|
||||
@@ -53,7 +54,7 @@ impl Account {
|
||||
object: ObjectType,
|
||||
filter: impl IntoIterator<Item = (impl Display, impl Into<Value>)>,
|
||||
sort_by: impl IntoIterator<Item = impl Display>,
|
||||
) -> JmapResponse {
|
||||
) -> Vec<Id> {
|
||||
let name = object.as_str();
|
||||
|
||||
self.jmap_query(
|
||||
@@ -63,6 +64,8 @@ impl Account {
|
||||
Vec::<(&str, &str)>::new(),
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn registry_destroy(
|
||||
@@ -106,6 +109,35 @@ impl Account {
|
||||
pub async fn registry_create_object<T: ObjectImpl>(&self, item: T) -> Id {
|
||||
self.registry_create([item]).await.created_id(0)
|
||||
}
|
||||
|
||||
pub async fn registry_create_object_expect_err<T: ObjectImpl>(&self, item: T) -> JmapSetError {
|
||||
let v = self
|
||||
.registry_create([item])
|
||||
.await
|
||||
.not_created(0)
|
||||
.to_string();
|
||||
serde_json::from_str(&v).expect("Failed to deserialize set error")
|
||||
}
|
||||
|
||||
pub async fn registry_update_object(&self, object: ObjectType, id: Id, item: Value) {
|
||||
self.registry_update(object, [(id, item)])
|
||||
.await
|
||||
.updated_id(id);
|
||||
}
|
||||
|
||||
pub async fn registry_update_object_expect_err(
|
||||
&self,
|
||||
object: ObjectType,
|
||||
id: Id,
|
||||
item: Value,
|
||||
) -> JmapSetError {
|
||||
let v = self
|
||||
.registry_update(object, [(id, item)])
|
||||
.await
|
||||
.not_updated(&id.to_string())
|
||||
.to_string();
|
||||
serde_json::from_str(&v).expect("Failed to deserialize set error")
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapResponse {
|
||||
@@ -128,3 +160,18 @@ impl UnwrapRegistryId for RegistryWriteResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_server_set_props(value: &mut serde_json::Value) {
|
||||
if let Value::Object(obj) = value {
|
||||
let is_app_pass = obj
|
||||
.get("@type")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|t| ["AppPassword", "ApiKey"].contains(&t));
|
||||
obj.retain(|k, _| {
|
||||
!(["createdAt", "credentialId"].contains(&k.as_str()) || (is_app_pass && k == "secret"))
|
||||
});
|
||||
for v in obj.values_mut() {
|
||||
remove_server_set_props(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,34 +160,28 @@ impl TestServerBuilder {
|
||||
let level = std::env::var("LOG")
|
||||
.map(|log| TracingLevel::parse(&log).expect("Invalid log level"))
|
||||
.ok();
|
||||
self.bootstrap
|
||||
.registry
|
||||
.write(RegistryWrite::insert(
|
||||
&Tracer::Stdout(TracerStdout {
|
||||
enable: level.is_some(),
|
||||
level: level.unwrap_or(TracingLevel::Info),
|
||||
ansi: true,
|
||||
multiline: false,
|
||||
events: Map::new(
|
||||
EventType::variants()
|
||||
.iter()
|
||||
.filter(|ev| {
|
||||
let ev = ev.as_str();
|
||||
ev.starts_with("network.")
|
||||
|| ev == "telemetry.webhook-error"
|
||||
|| ev == "http.request-body"
|
||||
})
|
||||
.copied()
|
||||
.collect(),
|
||||
),
|
||||
events_policy: EventPolicy::Exclude,
|
||||
..Default::default()
|
||||
})
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
|
||||
self.insert_object(Tracer::Stdout(TracerStdout {
|
||||
enable: level.is_some(),
|
||||
level: level.unwrap_or(TracingLevel::Info),
|
||||
ansi: true,
|
||||
multiline: false,
|
||||
events: Map::new(
|
||||
EventType::variants()
|
||||
.iter()
|
||||
.filter(|ev| {
|
||||
let ev = ev.as_str();
|
||||
ev.starts_with("network.")
|
||||
|| ev == "telemetry.webhook-error"
|
||||
|| ev == "http.request-body"
|
||||
})
|
||||
.copied()
|
||||
.collect(),
|
||||
),
|
||||
events_policy: EventPolicy::Exclude,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Start listeners
|
||||
let mut servers = Listeners::parse(&mut self.bootstrap).await;
|
||||
|
||||
Reference in New Issue
Block a user