Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{http::HttpRequest, server::TestServerBuilder};
|
||||
use registry::{
|
||||
schema::structs::{Directory, OidcDirectory},
|
||||
types::map::Map,
|
||||
};
|
||||
|
||||
const EXTERNAL_ENDPOINT: &str =
|
||||
"http://localhost:9080/realms/stalwart/protocol/openid-connect/auth";
|
||||
const INTERNAL_ENDPOINT: &str = "https://127.0.0.1:8899/login";
|
||||
|
||||
pub async fn test() {
|
||||
println!("Running OIDC account discovery tests...");
|
||||
crate::utils::containers::ensure_keycloak().await;
|
||||
let test = TestServerBuilder::new("directory_discovery_test")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.disable_services()
|
||||
.with_object(Directory::Oidc(oidc_test_directory()))
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
assert!(
|
||||
test.server
|
||||
.get_default_directory()
|
||||
.and_then(|directory| directory.oidc_discovery_document())
|
||||
.is_some_and(|discovery| discovery.document.authorization_endpoint
|
||||
== EXTERNAL_ENDPOINT),
|
||||
"The default directory is not the test OpenID Connect provider"
|
||||
);
|
||||
let http = HttpRequest::new();
|
||||
|
||||
for (account_name, expected) in [
|
||||
("[email protected]", EXTERNAL_ENDPOINT),
|
||||
("[email protected]", EXTERNAL_ENDPOINT),
|
||||
(
|
||||
"[email protected]%[email protected]",
|
||||
EXTERNAL_ENDPOINT,
|
||||
),
|
||||
("admin", INTERNAL_ENDPOINT),
|
||||
("[email protected]%admin", INTERNAL_ENDPOINT),
|
||||
("[email protected]%Admin", INTERNAL_ENDPOINT),
|
||||
("john.doe@", INTERNAL_ENDPOINT),
|
||||
] {
|
||||
assert_eq!(
|
||||
authorization_endpoint(&http, account_name).await,
|
||||
expected,
|
||||
"Unexpected discovery document for {account_name:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorization_endpoint(http: &HttpRequest, account_name: &str) -> String {
|
||||
http.get::<serde_json::Value>(&format!(
|
||||
"/api/discover/{}",
|
||||
account_name.replace('%', "%25")
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.get("authorization_endpoint")
|
||||
.and_then(|endpoint| endpoint.as_str())
|
||||
.unwrap_or_else(|| panic!("No authorization endpoint returned for {account_name:?}"))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn oidc_test_directory() -> OidcDirectory {
|
||||
OidcDirectory {
|
||||
description: "Test OIDC directory".to_string(),
|
||||
issuer_url: "http://localhost:9080/realms/stalwart".to_string(),
|
||||
claim_username: "preferred_username".to_string(),
|
||||
claim_name: Some("name".to_string()),
|
||||
claim_groups: Some("groups".to_string()),
|
||||
require_audience: Some("stalwart".to_string()),
|
||||
require_scopes: Map::new(vec![
|
||||
"email".to_string(),
|
||||
"profile".to_string(),
|
||||
"openid".to_string(),
|
||||
]),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
directory::ldap::ldap_test_directory,
|
||||
utils::{server::TestServerBuilder, smtp::SmtpConnection},
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use common::auth::{AuthRequest, RECOVERY_ADMIN_ID};
|
||||
use email::cache::MessageCacheFetch;
|
||||
use registry::schema::structs::{Account, AccountSettings, Directory};
|
||||
use std::net::IpAddr;
|
||||
use types::id::Id;
|
||||
|
||||
pub async fn test() {
|
||||
println!("Running directory integration tests...");
|
||||
crate::utils::containers::ensure_openldap().await;
|
||||
let test = TestServerBuilder::new("directory_integration_test")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.with_object(Directory::Ldap(ldap_test_directory()))
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
let admin = test.account("admin");
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_disable_spam_filter().await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Test account creation by login
|
||||
let account = crate::utils::account::Account::new(
|
||||
"[email protected]",
|
||||
"this is John's LDAP password",
|
||||
&[],
|
||||
"",
|
||||
Id::from(u32::MAX),
|
||||
);
|
||||
assert_eq!(
|
||||
account
|
||||
.registry_get::<AccountSettings>(Id::singleton())
|
||||
.await
|
||||
.description
|
||||
.as_deref(),
|
||||
Some("John Doe")
|
||||
);
|
||||
|
||||
// Test account creation by rcpt
|
||||
let mut lmtp = SmtpConnection::connect().await;
|
||||
for rcpt in [
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
] {
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&[rcpt],
|
||||
&TEST_EMAIL.replace("$RCPT", rcpt),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Fetch all accounts
|
||||
let mut accounts = admin
|
||||
.registry_get_all::<Account>()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(id, account)| {
|
||||
(
|
||||
match &account {
|
||||
Account::User(user_account) => user_account.name.clone(),
|
||||
Account::Group(group_account) => group_account.name.clone(),
|
||||
},
|
||||
(account, id),
|
||||
)
|
||||
})
|
||||
.collect::<AHashMap<_, _>>();
|
||||
assert_eq!(accounts.len(), 5, "Got: {accounts:#?}");
|
||||
|
||||
// Validate accounts
|
||||
for (name, description, secret, groups, aliases) in [
|
||||
(
|
||||
"john.doe",
|
||||
"John Doe",
|
||||
"$app$8958830913002348890$",
|
||||
&["sales"][..],
|
||||
&["john"][..],
|
||||
),
|
||||
(
|
||||
"jane.smith",
|
||||
"Jane Smith",
|
||||
"$app$4096614298472586996$",
|
||||
&["sales", "corporate"][..],
|
||||
&[][..],
|
||||
),
|
||||
(
|
||||
"bill.foobar",
|
||||
"Bill Foobar",
|
||||
"",
|
||||
&["corporate"][..],
|
||||
&["bill"][..],
|
||||
),
|
||||
] {
|
||||
let (account, id) = accounts
|
||||
.remove(name)
|
||||
.map(|(account, id)| (account.into_user().unwrap(), id))
|
||||
.unwrap();
|
||||
assert_eq!(account.description.as_deref(), Some(description));
|
||||
if !secret.is_empty() {
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.object::<Account>(id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_user()
|
||||
.unwrap()
|
||||
.credentials
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|v| v.as_main_credential())
|
||||
.map(|v| v.secret.as_str()),
|
||||
Some(secret)
|
||||
);
|
||||
}
|
||||
for group in groups {
|
||||
let id = accounts.get(*group).unwrap().1;
|
||||
assert!(
|
||||
account
|
||||
.member_group_ids
|
||||
.iter()
|
||||
.any(|group_id| group_id == &id),
|
||||
"Account {name} is not a member of group {group}"
|
||||
);
|
||||
}
|
||||
for alias in aliases {
|
||||
assert!(
|
||||
account
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|account_alias| account_alias.name == *alias),
|
||||
"Account {name} does not have alias {alias}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.index
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// Validate groups
|
||||
for (name, description, aliases) in [
|
||||
("sales", "sales", &[][..]),
|
||||
("corporate", "corporate", &["everyone"][..]),
|
||||
] {
|
||||
let (account, id) = accounts
|
||||
.remove(name)
|
||||
.map(|(account, id)| (account.into_group().unwrap(), id))
|
||||
.unwrap();
|
||||
assert_eq!(account.description.as_deref(), Some(description));
|
||||
for alias in aliases {
|
||||
assert!(
|
||||
account
|
||||
.aliases
|
||||
.iter()
|
||||
.any(|account_alias| account_alias.name == *alias),
|
||||
"Group {name} does not have alias {alias}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.index
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// Test recovery admin impersonation of an account that has not logged in before
|
||||
assert!(
|
||||
test.server
|
||||
.account_id_from_email("[email protected]", false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"Account [email protected] exists before impersonation"
|
||||
);
|
||||
let access_token = test
|
||||
.server
|
||||
.authenticate(&AuthRequest::from_plain(
|
||||
"[email protected]%admin",
|
||||
admin.secret(),
|
||||
0,
|
||||
IpAddr::from([127, 0, 0, 1]),
|
||||
))
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to impersonate [email protected]: {err:?}"));
|
||||
assert_ne!(access_token.account_id(), RECOVERY_ADMIN_ID);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.object::<Account>(access_token.account_id().into())
|
||||
.await
|
||||
.unwrap()
|
||||
.and_then(|account| account.into_user())
|
||||
.map(|account| account.name),
|
||||
Some("multi.mail".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
const TEST_EMAIL: &str = r#"From: [email protected]
|
||||
To: $RCPT
|
||||
Subject: TPS Report for $RCPT
|
||||
|
||||
I'm going to need those TPS reports ASAP. So, if you could do that, that'd be great.
|
||||
|
||||
"#;
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use directory::{Account, Credentials, Group, Recipient, backend::ldap::LdapDirectory};
|
||||
use registry::{
|
||||
schema::structs::{self, SecretKeyOptional, SecretKeyValue},
|
||||
types::map::Map,
|
||||
};
|
||||
|
||||
pub async fn test() {
|
||||
println!("Running LDAP directory tests...");
|
||||
crate::utils::containers::ensure_openldap().await;
|
||||
let mut config = ldap_test_directory();
|
||||
|
||||
// Test bind authentication
|
||||
let ldap = LdapDirectory::open(config.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "this is John's LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "[email protected]".into(),
|
||||
email_aliases: vec!["[email protected]".into()],
|
||||
secret: Some("$app$8958830913002348890$".into()),
|
||||
groups: Some(vec!["[email protected]".into()]),
|
||||
description: Some("John Doe".into()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "this is Jane's LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "[email protected]".into(),
|
||||
email_aliases: vec![],
|
||||
secret: Some("$app$4096614298472586996$".into()),
|
||||
groups: Some(vec![
|
||||
"[email protected]".into(),
|
||||
"[email protected]".into()
|
||||
]),
|
||||
description: Some("Jane Smith".into()),
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "this is a wrong LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err(),
|
||||
"Empty password accepted during bind authentication"
|
||||
);
|
||||
|
||||
// Test direct authentication (without bind)
|
||||
config.attr_secret = Map::new(vec!["userPassword".to_string()]);
|
||||
config.attr_secret_changed = Map::new(vec![]);
|
||||
config.bind_authentication = false;
|
||||
let ldap = LdapDirectory::open(config.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "this is John's LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "[email protected]".into(),
|
||||
email_aliases: vec!["[email protected]".into()],
|
||||
secret: Some("this is John's LDAP password".into()),
|
||||
groups: Some(vec!["[email protected]".into()]),
|
||||
description: Some("John Doe".into()),
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "this is a wrong LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err(),
|
||||
"Empty password accepted during direct authentication"
|
||||
);
|
||||
|
||||
// Test recipient lookup
|
||||
assert_eq!(
|
||||
ldap.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Account(Account {
|
||||
email: "[email protected]".into(),
|
||||
email_aliases: vec!["[email protected]".into()],
|
||||
secret: Some("this is John's LDAP password".into()),
|
||||
groups: Some(vec!["[email protected]".into()]),
|
||||
description: Some("John Doe".into())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ldap.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Account(Account {
|
||||
email: "[email protected]".into(),
|
||||
email_aliases: vec![],
|
||||
secret: Some("this is Jane's LDAP password".into()),
|
||||
groups: Some(vec![
|
||||
"[email protected]".into(),
|
||||
"[email protected]".into()
|
||||
]),
|
||||
description: Some("Jane Smith".into())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ldap.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Group(Group {
|
||||
email: "[email protected]".into(),
|
||||
email_aliases: vec![],
|
||||
description: Some("sales".into())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ldap.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Group(Group {
|
||||
email: "[email protected]".into(),
|
||||
email_aliases: vec!["[email protected]".into()],
|
||||
description: Some("corporate".into())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ldap.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Invalid
|
||||
);
|
||||
|
||||
const MULTI_MAIL: &[&str] = &[
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
];
|
||||
let mut config = ldap_test_directory();
|
||||
config.attr_secret = Map::new(vec!["userPassword".to_string()]);
|
||||
config.attr_secret_changed = Map::new(vec![]);
|
||||
config.bind_authentication = false;
|
||||
let ldap_dedicated_attr = LdapDirectory::open(config.clone()).await.unwrap();
|
||||
config.attr_email_alias = Map::new(vec!["mail".to_string()]);
|
||||
let ldap_overloaded_attr = LdapDirectory::open(config).await.unwrap();
|
||||
|
||||
for address in MULTI_MAIL {
|
||||
let Recipient::Account(account) = ldap_dedicated_attr.recipient(address).await.unwrap()
|
||||
else {
|
||||
panic!("Expected an account for {address}");
|
||||
};
|
||||
assert!(
|
||||
MULTI_MAIL.contains(&account.email.as_str()),
|
||||
"Unexpected primary address {:?}",
|
||||
account.email
|
||||
);
|
||||
assert!(
|
||||
account.email_aliases.is_empty(),
|
||||
"Expected no aliases, got {:?}",
|
||||
account.email_aliases
|
||||
);
|
||||
|
||||
let Recipient::Account(account) = ldap_overloaded_attr.recipient(address).await.unwrap()
|
||||
else {
|
||||
panic!("Expected an account for {address}");
|
||||
};
|
||||
assert_eq!(sorted_addresses(&account), MULTI_MAIL);
|
||||
}
|
||||
|
||||
let account = ldap_overloaded_attr
|
||||
.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".into(),
|
||||
secret: "this is Multi's LDAP password".into(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sorted_addresses(&account), MULTI_MAIL);
|
||||
}
|
||||
|
||||
fn sorted_addresses(account: &Account) -> Vec<String> {
|
||||
let mut addresses = account.email_aliases.clone();
|
||||
addresses.push(account.email.clone());
|
||||
addresses.sort_unstable();
|
||||
addresses
|
||||
}
|
||||
|
||||
pub fn ldap_test_directory() -> structs::LdapDirectory {
|
||||
structs::LdapDirectory {
|
||||
url: "ldap://localhost".into(),
|
||||
use_tls: false,
|
||||
attr_class: Map::new(vec!["objectClass".to_string()]),
|
||||
attr_description: Map::new(vec!["cn".to_string()]),
|
||||
attr_email: Map::new(vec!["mail".to_string()]),
|
||||
attr_email_alias: Map::new(vec!["mailAlias".to_string()]),
|
||||
attr_member_of: Map::new(vec!["memberOf".to_string()]),
|
||||
attr_secret: Map::new(vec![]),
|
||||
attr_secret_changed: Map::new(vec!["shadowLastChange".to_string()]),
|
||||
base_dn: "dc=stalwart,dc=test".into(),
|
||||
bind_dn: "cn=admin,dc=stalwart,dc=test".to_string().into(),
|
||||
bind_secret: SecretKeyOptional::Value(SecretKeyValue {
|
||||
secret: "admin".into(),
|
||||
}),
|
||||
filter_member_of: "(&(objectClass=groupOfNames)(member=?))".to_string().into(),
|
||||
filter_login: "(&(objectClass=inetOrgPerson)(mail=?))".into(),
|
||||
filter_mailbox: concat!(
|
||||
"(|(&(objectClass=inetOrgPerson)(|(mail=?)(mailAlias=?)))",
|
||||
"(&(objectClass=groupOfNames)(|(mail=?)(mailAlias=?))))"
|
||||
)
|
||||
.into(),
|
||||
group_class: "groupOfNames".into(),
|
||||
bind_authentication: true,
|
||||
description: "Test LDAP directory".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod discovery;
|
||||
pub mod integration;
|
||||
pub mod ldap;
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub mod sql;
|
||||
pub mod synchronization;
|
||||
pub mod unavailable;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn directory_tests() {
|
||||
ldap::test().await;
|
||||
oidc::test().await;
|
||||
unavailable::test().await;
|
||||
discovery::test().await;
|
||||
#[cfg(feature = "sqlite")]
|
||||
sql::test().await;
|
||||
synchronization::test().await;
|
||||
integration::test().await;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use directory::{Account, Credentials, Group, Recipient, backend::sql::SqlDirectory};
|
||||
use registry::schema::structs::{self, SqlAuthStore};
|
||||
use store::{Store, backend::sqlite::SqliteStore};
|
||||
|
||||
pub async fn test() {
|
||||
println!("Running SQL directory tests...");
|
||||
let sql_store = Store::SQLite(SqliteStore::open_memory().unwrap().into());
|
||||
|
||||
// Create test directory
|
||||
for query in [
|
||||
concat!(
|
||||
"CREATE TABLE accounts (name TEXT PRIMARY KEY, secret TEXT, description TEXT,",
|
||||
" type TEXT NOT NULL, active BOOLEAN DEFAULT TRUE)"
|
||||
),
|
||||
concat!(
|
||||
"CREATE TABLE group_members (name TEXT NOT NULL, member_of ",
|
||||
"TEXT NOT NULL, PRIMARY KEY (name, member_of))"
|
||||
),
|
||||
concat!(
|
||||
"CREATE TABLE emails (name TEXT NOT NULL, address TEXT NOT",
|
||||
" NULL, PRIMARY KEY (name, address))"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO accounts (name, secret, description, type) ",
|
||||
"VALUES ('[email protected]', 'john secret', 'John Doe', 'individual')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO accounts (name, secret, description, type) ",
|
||||
"VALUES ('[email protected]', 'jane secret', 'Jane Doe', 'individual')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO accounts (name, secret, description, type) ",
|
||||
"VALUES ('[email protected]', 'bob secret', '', 'individual')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO accounts (name, secret, description, type) ",
|
||||
"VALUES ('[email protected]', '', '', 'individual')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO accounts (name, secret, description, type) ",
|
||||
"VALUES ('[email protected]', NULL, 'Sales Team', 'group')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO accounts (name, secret, description, type) ",
|
||||
"VALUES ('[email protected]', NULL, '', 'group')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO group_members (name, member_of) VALUES ",
|
||||
"('[email protected]', '[email protected]')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO group_members (name, member_of) VALUES ",
|
||||
"('[email protected]', '[email protected]')"
|
||||
),
|
||||
concat!(
|
||||
"INSERT INTO emails (name, address) VALUES ",
|
||||
"('[email protected]', '[email protected]')"
|
||||
),
|
||||
] {
|
||||
sql_store
|
||||
.sql_query::<usize>(query, vec![])
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("failed for {query}"));
|
||||
}
|
||||
|
||||
let config = structs::SqlDirectory {
|
||||
description: "Test SQL directory".to_string(),
|
||||
query_login: concat!(
|
||||
"SELECT name, secret, description, type FROM accounts ",
|
||||
"WHERE name = $1 AND active = true"
|
||||
)
|
||||
.into(),
|
||||
query_recipient: concat!(
|
||||
"SELECT name, secret, description, type FROM accounts ",
|
||||
"WHERE name = $1 AND active = true"
|
||||
)
|
||||
.into(),
|
||||
query_email_aliases: concat!("SELECT address FROM emails ", "WHERE name = $1")
|
||||
.to_string()
|
||||
.into(),
|
||||
query_member_of: concat!("SELECT member_of FROM group_members ", "WHERE name = $1")
|
||||
.to_string()
|
||||
.into(),
|
||||
column_class: "type".to_string().into(),
|
||||
column_description: "description".to_string().into(),
|
||||
column_email: "name".into(),
|
||||
column_secret: "secret".into(),
|
||||
store: SqlAuthStore::Default,
|
||||
member_tenant_id: None,
|
||||
};
|
||||
|
||||
// Test authentication
|
||||
let sql = SqlDirectory::open(config, &sql_store).await.unwrap();
|
||||
assert_eq!(
|
||||
sql.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".to_string(),
|
||||
secret: "john secret".to_string(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec!["[email protected]".to_string(),],
|
||||
secret: Some("john secret".to_string()),
|
||||
groups: Some(vec!["[email protected]".to_string()]),
|
||||
description: Some("John Doe".to_string()),
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
sql.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".to_string(),
|
||||
secret: "wrong secret".to_string(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Empty columns are treated as missing values
|
||||
assert_eq!(
|
||||
sql.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".to_string(),
|
||||
secret: "bob secret".to_string(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
Account {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec![],
|
||||
secret: Some("bob secret".to_string()),
|
||||
groups: Some(vec![]),
|
||||
description: None,
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
sql.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".to_string(),
|
||||
secret: "".to_string(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
sql.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Account(Account {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec![],
|
||||
secret: None,
|
||||
groups: Some(vec![]),
|
||||
description: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
sql.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Group(Group {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec![],
|
||||
description: None
|
||||
})
|
||||
);
|
||||
|
||||
// Test recipient lookup
|
||||
assert_eq!(
|
||||
sql.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Account(Account {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec!["[email protected]".to_string()],
|
||||
secret: Some("john secret".to_string()),
|
||||
groups: Some(vec!["[email protected]".to_string()]),
|
||||
description: Some("John Doe".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
sql.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Account(Account {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec![],
|
||||
secret: Some("jane secret".to_string()),
|
||||
groups: Some(vec!["[email protected]".to_string()]),
|
||||
description: Some("Jane Doe".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
sql.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Group(Group {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec![],
|
||||
description: Some("Sales Team".to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
sql.recipient("[email protected]").await.unwrap(),
|
||||
Recipient::Invalid
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use registry::schema::{
|
||||
prelude::ObjectType,
|
||||
structs::{Account, Domain, EmailAlias},
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
pub async fn test() {
|
||||
println!("Running directory synchronization tests...");
|
||||
let test = TestServerBuilder::new("directory_synchronization_test")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.disable_services()
|
||||
.build()
|
||||
.await;
|
||||
let admin = test.account("admin");
|
||||
|
||||
// Synchronizing an account with an unknown domain should fail
|
||||
assert!(
|
||||
test.server
|
||||
.synchronize_account(directory::Account {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec![],
|
||||
secret: "supersecret".to_string().into(),
|
||||
groups: Some(vec![]),
|
||||
description: "John Doe".to_string().into(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Initial account synchronization
|
||||
let mut account_in = directory::Account {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
],
|
||||
secret: "supersecret".to_string().into(),
|
||||
groups: Some(vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
]),
|
||||
description: "John Doe".to_string().into(),
|
||||
};
|
||||
let result = test
|
||||
.server
|
||||
.synchronize_account(account_in.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let account_id = Id::from(result.id);
|
||||
let account_out = test
|
||||
.server
|
||||
.registry()
|
||||
.object::<Account>(account_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_user()
|
||||
.unwrap();
|
||||
let domain_id = account_out.domain_id;
|
||||
assert_eq!(
|
||||
admin.registry_get::<Domain>(domain_id).await.name,
|
||||
"example.org"
|
||||
);
|
||||
assert_eq!(account_out.name, "john");
|
||||
assert_eq!(account_out.description.as_deref(), Some("John Doe"));
|
||||
assert_eq!(
|
||||
account_out
|
||||
.credentials
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|v| v.as_main_credential())
|
||||
.map(|c| c.secret.as_str()),
|
||||
Some("supersecret")
|
||||
);
|
||||
assert_eq!(account_out.aliases.len(), 2);
|
||||
let aliases = account_out.aliases.iter().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
aliases[0],
|
||||
&EmailAlias {
|
||||
description: None,
|
||||
domain_id,
|
||||
enabled: true,
|
||||
name: "john.doe".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
aliases[1],
|
||||
&EmailAlias {
|
||||
description: None,
|
||||
domain_id,
|
||||
enabled: true,
|
||||
name: "j.doe".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(account_out.member_group_ids.len(), 2);
|
||||
for (idx, group_id) in account_out.member_group_ids.iter().enumerate() {
|
||||
let group = admin
|
||||
.registry_get::<Account>(*group_id)
|
||||
.await
|
||||
.into_group()
|
||||
.unwrap();
|
||||
assert_eq!(group.name, if idx == 0 { "corporate" } else { "sales" });
|
||||
assert_eq!(group.domain_id, domain_id);
|
||||
}
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.count_object(ObjectType::Account)
|
||||
.await
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.count_object(ObjectType::Domain)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
|
||||
// No changes should not cause any updates
|
||||
assert_eq!(
|
||||
test.server
|
||||
.synchronize_account(account_in.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.id,
|
||||
account_id.document_id()
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.object::<Account>(account_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_user()
|
||||
.unwrap(),
|
||||
account_out
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.count_object(ObjectType::Account)
|
||||
.await
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
|
||||
// Make some changes and synchronize again
|
||||
account_in.description = "Johnathan Doe".to_string().into();
|
||||
account_in
|
||||
.email_aliases
|
||||
.push("[email protected]".to_string());
|
||||
let groups = account_in.groups.get_or_insert_default();
|
||||
groups.pop();
|
||||
groups.push("[email protected]".to_string());
|
||||
account_in.secret = "evenmoresecret".to_string().into();
|
||||
assert_eq!(
|
||||
test.server
|
||||
.synchronize_account(account_in.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.id,
|
||||
account_id.document_id()
|
||||
);
|
||||
let account_out = test
|
||||
.server
|
||||
.registry()
|
||||
.object::<Account>(account_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_user()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
account_out
|
||||
.credentials
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|v| v.as_main_credential())
|
||||
.map(|c| c.secret.as_str()),
|
||||
Some("evenmoresecret")
|
||||
);
|
||||
assert_eq!(account_out.description.as_deref(), Some("Johnathan Doe"));
|
||||
assert_eq!(account_out.aliases.len(), 3);
|
||||
let aliases = account_out.aliases.iter().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
aliases[2],
|
||||
&EmailAlias {
|
||||
description: None,
|
||||
domain_id,
|
||||
enabled: true,
|
||||
name: "johnny".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(account_out.member_group_ids.len(), 2);
|
||||
let account_groups = account_out
|
||||
.member_group_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
for (idx, group_id) in account_groups.iter().enumerate() {
|
||||
let group = admin
|
||||
.registry_get::<Account>(*group_id)
|
||||
.await
|
||||
.into_group()
|
||||
.unwrap();
|
||||
assert_eq!(group.name, if idx == 0 { "corporate" } else { "support" });
|
||||
assert_eq!(group.domain_id, domain_id);
|
||||
}
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.count_object(ObjectType::Account)
|
||||
.await
|
||||
.unwrap(),
|
||||
4
|
||||
);
|
||||
|
||||
account_in.groups = None;
|
||||
test.server
|
||||
.synchronize_account(account_in.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let account_out = test
|
||||
.server
|
||||
.registry()
|
||||
.object::<Account>(account_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_user()
|
||||
.unwrap();
|
||||
assert_eq!(account_out.member_group_ids.len(), 2);
|
||||
|
||||
account_in.groups = Some(vec![]);
|
||||
test.server
|
||||
.synchronize_account(account_in.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let account_out = test
|
||||
.server
|
||||
.registry()
|
||||
.object::<Account>(account_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_user()
|
||||
.unwrap();
|
||||
assert_eq!(account_out.member_group_ids.len(), 0);
|
||||
|
||||
// Synchronize a group
|
||||
assert_eq!(
|
||||
test.server
|
||||
.synchronize_group(directory::Group {
|
||||
email: "[email protected]".to_string(),
|
||||
email_aliases: vec!["[email protected]".to_string()],
|
||||
description: "Corporate Group".to_string().into(),
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
account_groups[0].document_id()
|
||||
);
|
||||
let group_out = test
|
||||
.server
|
||||
.registry()
|
||||
.object::<Account>(account_groups[0])
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_group()
|
||||
.unwrap();
|
||||
assert_eq!(group_out.name, "corporate");
|
||||
assert_eq!(group_out.description.as_deref(), Some("Corporate Group"));
|
||||
assert_eq!(group_out.aliases.len(), 1);
|
||||
let aliases = group_out.aliases.iter().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
aliases[0],
|
||||
&EmailAlias {
|
||||
description: None,
|
||||
domain_id,
|
||||
enabled: true,
|
||||
name: "everyone".to_string(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.registry()
|
||||
.count_object(ObjectType::Account)
|
||||
.await
|
||||
.unwrap(),
|
||||
4
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use directory::{Credentials, Directory, UnavailableDirectory, backend::oidc::OpenIdDirectory};
|
||||
use registry::{
|
||||
schema::{enums::DirectoryType, structs},
|
||||
types::map::Map,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DISCOVERY_RETRY_FOR: Duration = Duration::from_secs(30);
|
||||
|
||||
pub async fn test() {
|
||||
println!("Running unavailable directory tests...");
|
||||
|
||||
let started_at = Instant::now();
|
||||
let error = OpenIdDirectory::open(structs::OidcDirectory {
|
||||
description: "Unreachable OIDC directory".to_string(),
|
||||
issuer_url: "http://localhost:59999/realms/stalwart".to_string(),
|
||||
claim_username: "preferred_username".to_string(),
|
||||
claim_name: None,
|
||||
claim_groups: None,
|
||||
username_domain: None,
|
||||
require_audience: None,
|
||||
require_scopes: Map::new(vec![]),
|
||||
member_tenant_id: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("Discovery against an unreachable issuer must fail");
|
||||
let elapsed = started_at.elapsed();
|
||||
assert!(
|
||||
elapsed >= DISCOVERY_RETRY_FOR,
|
||||
"Discovery gave up after {elapsed:?}: {error}"
|
||||
);
|
||||
|
||||
let oidc = Directory::Unavailable(UnavailableDirectory::new(DirectoryType::Oidc, error));
|
||||
assert!(
|
||||
oidc.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".to_string(),
|
||||
secret: "this is an OIDC password".to_string(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err(),
|
||||
"Password authentication must not fall back to internal credentials"
|
||||
);
|
||||
assert!(
|
||||
oidc.authenticate(&Credentials::Bearer {
|
||||
username: None,
|
||||
token: "not a token".to_string(),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(oidc.has_bearer_token_support());
|
||||
assert!(!oidc.can_lookup_recipients());
|
||||
assert!(oidc.oidc_discovery_document().is_none());
|
||||
|
||||
let ldap = Directory::Unavailable(UnavailableDirectory::new(
|
||||
DirectoryType::Ldap,
|
||||
"LDAP bind password is required when bind DN is set",
|
||||
));
|
||||
assert!(
|
||||
ldap.authenticate(&Credentials::Basic {
|
||||
username: "[email protected]".to_string(),
|
||||
secret: "this is John's LDAP password".to_string(),
|
||||
mfa_token: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(ldap.recipient("[email protected]").await.is_err());
|
||||
assert!(ldap.can_lookup_recipients());
|
||||
assert!(!ldap.has_bearer_token_support());
|
||||
}
|
||||
Reference in New Issue
Block a user