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,816 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||
use email::mailbox::{DRAFTS_ID, INBOX_ID, JUNK_ID};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{Permission, TaskSpamFilterMaintenanceType, TaskStoreMaintenanceType},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
Permissions, PermissionsList, SpamTrainingSample, Task, TaskSpamFilterMaintenance,
|
||||
TaskStatus, TaskStoreMaintenance,
|
||||
},
|
||||
},
|
||||
types::map::Map,
|
||||
};
|
||||
use serde_json::json;
|
||||
use store::write::now;
|
||||
use types::{id::Id, keyword::Keyword};
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Email Spam classifier tests...");
|
||||
|
||||
// Create test accounts
|
||||
let admin = test.account("[email protected]");
|
||||
let account = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let other_account = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let client = account.jmap_client().await;
|
||||
let account_id = account.id().document_id();
|
||||
|
||||
// Make sure there are no spam training samples
|
||||
admin
|
||||
.registry_destroy_all(ObjectType::SpamTrainingSample)
|
||||
.await;
|
||||
assert!(
|
||||
admin
|
||||
.registry_query(
|
||||
ObjectType::SpamTrainingSample,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
.ids()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Import samples
|
||||
let mut spam_ids = vec![];
|
||||
let mut ham_ids = vec![];
|
||||
for (idx, samples) in [&SPAM, &HAM].into_iter().enumerate() {
|
||||
let is_spam = idx == 0;
|
||||
for (num, sample) in samples.iter().enumerate() {
|
||||
let mut mailbox_ids = vec![];
|
||||
let mut keywords = vec![];
|
||||
|
||||
if num == 0 {
|
||||
if is_spam {
|
||||
mailbox_ids.push(Id::from(JUNK_ID).to_string());
|
||||
keywords.push(Keyword::Junk.to_string());
|
||||
} else {
|
||||
mailbox_ids.push(Id::from(INBOX_ID).to_string());
|
||||
keywords.push(Keyword::NotJunk.to_string());
|
||||
}
|
||||
} else {
|
||||
mailbox_ids.push(Id::from(DRAFTS_ID).to_string());
|
||||
}
|
||||
|
||||
let mail_id = client
|
||||
.email_import(
|
||||
sample.as_bytes().to_vec(),
|
||||
&mailbox_ids,
|
||||
Some(&keywords),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id();
|
||||
if is_spam {
|
||||
spam_ids.push(mail_id);
|
||||
} else {
|
||||
ham_ids.push(mail_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let samples = account.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 1);
|
||||
|
||||
// Other users should no see the training samples
|
||||
let samples = other_account.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 0);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 0);
|
||||
|
||||
// The admin user should see all training samples
|
||||
let samples = admin.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 1);
|
||||
|
||||
// Train the classifier via JMAP
|
||||
for (ids, is_spam) in [(&spam_ids, true), (&ham_ids, false)] {
|
||||
for (idx, id) in ids.iter().skip(1).enumerate() {
|
||||
// Set keywords and mailboxes
|
||||
let mut request = client.build();
|
||||
let req = request.set_email().update(id);
|
||||
if idx < 5 || !is_spam {
|
||||
// Update via keywords
|
||||
let keyword = if is_spam {
|
||||
Keyword::Junk
|
||||
} else {
|
||||
Keyword::NotJunk
|
||||
}
|
||||
.to_string();
|
||||
req.keywords([&keyword]);
|
||||
} else {
|
||||
// Update via mailbox
|
||||
let mailbox_id = if is_spam { JUNK_ID } else { INBOX_ID };
|
||||
req.mailbox_ids([&Id::from(mailbox_id).to_string()]);
|
||||
}
|
||||
|
||||
request.send_set_email().await.unwrap().updated(id).unwrap();
|
||||
}
|
||||
}
|
||||
let samples = account.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 10);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 10);
|
||||
|
||||
// Make sure the email details are available in the sample
|
||||
assert_eq!(samples[0].1.subject, "save up to = on life insurance");
|
||||
assert_eq!(samples[0].1.from, "[email protected]");
|
||||
|
||||
// Reclassifying an email should not add a new sample
|
||||
let mut request = client.build();
|
||||
request
|
||||
.set_email()
|
||||
.update(&ham_ids[0])
|
||||
.keywords([Keyword::Junk.to_string()]);
|
||||
request
|
||||
.send_set_email()
|
||||
.await
|
||||
.unwrap()
|
||||
.updated(&ham_ids[0])
|
||||
.unwrap();
|
||||
|
||||
admin
|
||||
.registry_create_object(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
|
||||
maintenance_type: TaskSpamFilterMaintenanceType::Train,
|
||||
status: TaskStatus::now(),
|
||||
}))
|
||||
.await;
|
||||
test.wait_for_tasks().await;
|
||||
|
||||
let samples = account.spam_training_samples().await;
|
||||
assert_eq!(samples.len(), 20);
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 9);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11);
|
||||
let hold_for = test
|
||||
.server
|
||||
.core
|
||||
.spam
|
||||
.classifier
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hold_samples_for;
|
||||
assert!(
|
||||
hold_for > 2 * 86400,
|
||||
"hold for {} should be greater than 2 days",
|
||||
hold_for
|
||||
);
|
||||
let hold_until = now() + hold_for;
|
||||
let hold_range = (hold_until - 86400)..=hold_until;
|
||||
|
||||
assert!(samples.iter().all(|(_, s)| {
|
||||
s.blob_id.class.account_id() == account_id
|
||||
&& !s.delete_after_use
|
||||
&& hold_range.contains(&(s.expires_at.timestamp() as u64))
|
||||
}));
|
||||
|
||||
// Purging blobs should not remove training samples
|
||||
admin
|
||||
.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
|
||||
maintenance_type: TaskStoreMaintenanceType::PurgeBlob,
|
||||
shard_index: None,
|
||||
status: TaskStatus::now(),
|
||||
}))
|
||||
.await;
|
||||
test.wait_for_tasks().await;
|
||||
let samples = account.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 9);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11);
|
||||
assert_eq!(samples.len(), 20);
|
||||
|
||||
// Adding a training sample without permissions should fail
|
||||
assert_eq!(
|
||||
account
|
||||
.registry_create_many(
|
||||
ObjectType::SpamTrainingSample,
|
||||
[json!({
|
||||
Property::BlobId: samples[0].1.blob_id.clone(),
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.method_response()
|
||||
.text_field("type"),
|
||||
"forbidden"
|
||||
);
|
||||
|
||||
// Update permissions and try again
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
account.id(),
|
||||
json!({
|
||||
Property::Permissions: Permissions::Merge(PermissionsList {
|
||||
disabled_permissions: Map::default(),
|
||||
enabled_permissions: Map::new(vec![Permission::SysSpamTrainingSampleCreate]),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let sample_id = account
|
||||
.registry_create_many(
|
||||
ObjectType::SpamTrainingSample,
|
||||
[json!({
|
||||
Property::BlobId: samples[0].1.blob_id.clone(),
|
||||
Property::IsSpam: true,
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.created_id(0);
|
||||
let sample = account.registry_get::<SpamTrainingSample>(sample_id).await;
|
||||
assert_eq!(sample.subject, "save up to = on life insurance");
|
||||
assert_eq!(sample.from, "[email protected]");
|
||||
let samples = account.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 9);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 12);
|
||||
assert_eq!(samples.len(), 21);
|
||||
|
||||
// Delete account
|
||||
test.destroy_all_mailboxes(&account).await;
|
||||
account
|
||||
.registry_destroy_all(ObjectType::SpamTrainingSample)
|
||||
.await;
|
||||
test.assert_is_empty().await;
|
||||
|
||||
admin.destroy_account(account).await;
|
||||
admin.destroy_account(other_account).await;
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
pub const SPAM: [&str; 10] = [
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"Subject: save up to = on life insurance\r\n\r\n wh",
|
||||
"y spend more than you have to life quote savings e",
|
||||
"nsuring your family s financial security is very i",
|
||||
"mportant life quote savings makes buying life insu",
|
||||
"rance simple and affordable we provide free access",
|
||||
" to the very best companies and the lowest rates l",
|
||||
"ife quote savings is fast easy and saves you money",
|
||||
" let us help you get started with the best values ",
|
||||
"in the country on new coverage you can save hundre",
|
||||
"ds or even thousands of dollars by requesting a fr",
|
||||
"ee quote from lifequote savings our service will t",
|
||||
"ake you less than = minutes to complete shop ",
|
||||
"and compare save up to = on all types of life",
|
||||
" insurance hyperlink click here for your free quot",
|
||||
"e protecting your family is the best investment yo",
|
||||
"u ll ever make if you are in receipt of this email",
|
||||
" in error and or wish to be removed from our list ",
|
||||
"hyperlink please click here and type remove if you",
|
||||
" reside in any state which prohibits e mail solici",
|
||||
"tations for insurance please disregard this email\r\n",
|
||||
" \r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: a powerhouse gifting program\r\n\r\nyou don t ",
|
||||
"want to miss get in with the founders the major pl",
|
||||
"ayers are on this one for once be where the player",
|
||||
"s are this is your private invitation experts are ",
|
||||
"calling this the fastest way to huge cash flow eve",
|
||||
"r conceived leverage = = into = NUM",
|
||||
"BER over and over again the question here is you e",
|
||||
"ither want to be wealthy or you don t which one ar",
|
||||
"e you i am tossing you a financial lifeline and fo",
|
||||
"r your sake i hope you grab onto it and hold on ti",
|
||||
"ght for the ride of your life testimonials hear wh",
|
||||
"at average people are doing their first few days w",
|
||||
"e ve received = = in = day and we a",
|
||||
"re doing that over and over again q s in al i m a ",
|
||||
"single mother in fl and i ve received = NUMBE",
|
||||
"R in the last = days d s in fl i was not sure",
|
||||
" about this when i sent off my = = pledg",
|
||||
"e but i got back = = the very next day l",
|
||||
" l in ky i didn t have the money so i found myself",
|
||||
" a partner to work this with we have received NUMB",
|
||||
"ER = over the last = days i think i made",
|
||||
" the right decision don t you k c in fl i pick up ",
|
||||
"= = my first day and i they gave me free",
|
||||
" leads and all the training you can too j w in ca ",
|
||||
"announcing we will close your sales for you and he",
|
||||
"lp you get a fax blast immediately upon your entry",
|
||||
" you make the money free leads training don t wait",
|
||||
" call now fax back to = = = = ",
|
||||
"or call = = = = name__________",
|
||||
"________________________phone_____________________",
|
||||
"______________________ fax________________________",
|
||||
"_____________email________________________________",
|
||||
"____________ best time to call____________________",
|
||||
"_____time zone____________________________________",
|
||||
"____ this message is sent in compliance of the new",
|
||||
" e mail bill per section = paragraph a =",
|
||||
" c of s = further transmissions by the sender",
|
||||
" of this email may be stopped at no cost to you by",
|
||||
" sending a reply to this email address with the wo",
|
||||
"rd remove in the subject line errors omissions and",
|
||||
" exceptions excluded this is not spam i have compi",
|
||||
"led this list from our replicate database relative",
|
||||
" to seattle marketing group the gigt or turbo team",
|
||||
" for the sole purpose of these communications your",
|
||||
" continued inclusion is only by your gracious perm",
|
||||
"ission if you wish to not receive this mail from m",
|
||||
"e please send an email to tesrewinter with rem",
|
||||
"ove in the subject and you will be deleted immedia",
|
||||
"tely\r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: help wanted \r\n\r\nwe are a = year old f",
|
||||
"ortune = company that is growing at a tremend",
|
||||
"ous rate we are looking for individuals who want t",
|
||||
"o work from home this is an opportunity to make an",
|
||||
" excellent income no experience is required we wil",
|
||||
"l train you so if you are looking to be employed f",
|
||||
"rom home with a career that has vast opportunities",
|
||||
" then go we are looking for energetic and self",
|
||||
" motivated people if that is you than click on the",
|
||||
" link and fill out the form and one of our employe",
|
||||
"ment specialist will contact you to be removed fro",
|
||||
"m our link simple go to \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: tired of the bull out there\r\n\r\n want to st",
|
||||
"op losing money want a real money maker receive NU",
|
||||
"MBER = = = today experts are callin",
|
||||
"g this the fastest way to huge cash flow ever conc",
|
||||
"eived a powerhouse gifting program you don t want ",
|
||||
"to miss we work as a team this is your private inv",
|
||||
"itation get in with the founders this is where the",
|
||||
" big boys play the major players are on this one f",
|
||||
"or once be where the players are this is a system ",
|
||||
"that will drive = = s to your doorstep i",
|
||||
"n a short period of time leverage = = in",
|
||||
"to = = over and over again the question ",
|
||||
"here is you either want to be wealthy or you don t",
|
||||
" which one are you i am tossing you a financial li",
|
||||
"feline and for your sake i hope you grab onto it a",
|
||||
"nd hold on tight for the ride of your life testimo",
|
||||
"nials hear what average people are doing their fir",
|
||||
"st few days we ve received = = in =",
|
||||
" day and we are doing that over and over again q s",
|
||||
" in al i m a single mother in fl and i ve received",
|
||||
" = = in the last = days d s in fl i",
|
||||
" was not sure about this when i sent off my =",
|
||||
" = pledge but i got back = = the ve",
|
||||
"ry next day l l in ky i didn t have the money so i",
|
||||
" found myself a partner to work this with we have ",
|
||||
"received = = over the last = days i",
|
||||
" think i made the right decision don t you k c in ",
|
||||
"fl i pick up = = my first day and i they",
|
||||
" gave me free leads and all the training you can t",
|
||||
"oo j w in ca this will be the most important call ",
|
||||
"you make this year free leads training announcing ",
|
||||
"we will close your sales for you and help you get ",
|
||||
"a fax blast immediately upon your entry you make t",
|
||||
"he money free leads training don t wait call now N",
|
||||
"UMBER = = = print and fax to =",
|
||||
" = = = or send an email requesting ",
|
||||
"more information to successleads please includ",
|
||||
"e your name and telephone number receive = NU",
|
||||
"MBER free leads just for responding a = NUMBE",
|
||||
"R value name___________________________________ ph",
|
||||
"one___________________________________ fax________",
|
||||
"_____________________________ email_______________",
|
||||
"____________________ this message is sent in compl",
|
||||
"iance of the new e mail bill per section = pa",
|
||||
"ragraph a = c of s = further transmissio",
|
||||
"ns by the sender of this email may be stopped at n",
|
||||
"o cost to you by sending a reply to this email add",
|
||||
"ress with the word remove in the subject line erro",
|
||||
"rs omissions and exceptions excluded this is not s",
|
||||
"pam i have compiled this list from our replicate d",
|
||||
"atabase relative to seattle marketing group the gi",
|
||||
"gt or turbo team for the sole purpose of these com",
|
||||
"munications your continued inclusion is only by yo",
|
||||
"ur gracious permission if you wish to not receive ",
|
||||
"this mail from me please send an email to tesrewin",
|
||||
"ter with remove in the subject and you will be",
|
||||
" deleted immediately\r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: cellular phone accessories \r\n\r\n all at bel",
|
||||
"ow wholesale prices http = = = NUMB",
|
||||
"ER = sites merchant sales hands free ear buds",
|
||||
" = = phone holsters = = booste",
|
||||
"r antennas only = = phone cases = N",
|
||||
"UMBER car chargers = = face plates as lo",
|
||||
"w as = = lithium ion batteries as low as",
|
||||
" = = http = = = = NU",
|
||||
"MBER sites merchant sales click below for accessor",
|
||||
"ies on all nokia motorola lg nextel samsung qualco",
|
||||
"mm ericsson audiovox phones at below wholesale pri",
|
||||
"ces http = = = = = sites ",
|
||||
"merchant sales if you need assistance please call ",
|
||||
"us = = = to be removed from future ",
|
||||
"mailings please send your remove request to remove",
|
||||
" me now = thank you and have a super day\r\n",
|
||||
" \r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: conferencing made easy\r\n\r\n only = cen",
|
||||
"ts per minute including long distance no setup fee",
|
||||
"s no contracts or monthly fees call anytime from a",
|
||||
"nywhere to anywhere connects up to = particip",
|
||||
"ants simplicity in set up and administration opera",
|
||||
"tor help available = = the highest quali",
|
||||
"ty service for the lowest rate in the industry fil",
|
||||
"l out the form below to find out how you can lower",
|
||||
" your phone bill every month required input field ",
|
||||
"name web address company name state business phone",
|
||||
" home phone email address type of business to be r",
|
||||
"emoved from our distribution lists please hyperlin",
|
||||
"k click here\r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: dear friend\r\n\r\n i am mrs sese seko widow o",
|
||||
"f late president mobutu sese seko of zaire now kno",
|
||||
"wn as democratic republic of congo drc i am moved ",
|
||||
"to write you this letter this was in confidence co",
|
||||
"nsidering my presentcircumstance and situation i e",
|
||||
"scaped along with my husband and two of our sons g",
|
||||
"eorge kongolo and basher out of democratic republi",
|
||||
"c of congo drc to abidjan cote d ivoire where my f",
|
||||
"amily and i settled while we later moved to settle",
|
||||
"d in morroco where my husband later died of cancer",
|
||||
" disease however due to this situation we decided ",
|
||||
"to changed most of my husband s billions of dollar",
|
||||
"s deposited in swiss bank and other countries into",
|
||||
" other forms of money coded for safe purpose becau",
|
||||
"se the new head of state of dr mr laurent kabila h",
|
||||
"as made arrangement with the swiss government and ",
|
||||
"other european countries to freeze all my late hus",
|
||||
"band s treasures deposited in some european countr",
|
||||
"ies hence my children and i decided laying low in ",
|
||||
"africa to study the situation till when things get",
|
||||
"s better like now that president kabila is dead an",
|
||||
"d the son taking over joseph kabila one of my late",
|
||||
" husband s chateaux in southern france was confisc",
|
||||
"ated by the french government and as such i had to",
|
||||
" change my identity so that my investment will not",
|
||||
" be traced and confiscated i have deposited the su",
|
||||
"m eighteen million united state dollars us = ",
|
||||
"= = = with a security company for s",
|
||||
"afekeeping the funds are security coded to prevent",
|
||||
" them from knowing the content what i want you to ",
|
||||
"do is to indicate your interest that you will assi",
|
||||
"st us by receiving the money on our behalf acknowl",
|
||||
"edge this message so that i can introduce you to m",
|
||||
"y son kongolo who has the out modalities for the c",
|
||||
"laim of the said funds i want you to assist in inv",
|
||||
"esting this money but i will not want my identity ",
|
||||
"revealed i will also want to buy properties and st",
|
||||
"ock in multi national companies and to engage in o",
|
||||
"ther safe and non speculative investments may i at",
|
||||
" this point emphasise the high level of confidenti",
|
||||
"ality which this business demands and hope you wil",
|
||||
"l not betray the trust and confidence which i repo",
|
||||
"se in you in conclusion if you want to assist us m",
|
||||
"y son shall put you in the picture of the business",
|
||||
" tell you where the funds are currently being main",
|
||||
"tained and also discuss other modalities including",
|
||||
" remunerationfor your services for this reason kin",
|
||||
"dly furnish us your contact information that is yo",
|
||||
"ur personal telephone and fax number for confident",
|
||||
"ial regards mrs m sese seko\r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: lowest rates available for term life insu",
|
||||
"rance\r\n\r\n take a moment and fill out our online for",
|
||||
"m to see the low rate you qualify for save up to N",
|
||||
"UMBER from regular rates smokers accepted repr",
|
||||
"esenting quality nationwide carriers act now to ea",
|
||||
"sily remove your address from the list go to p",
|
||||
"lease allow = = hours for removal\r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: central bank of nigeria foreign remittanc",
|
||||
"e \r\n\r\n dept tinubu square lagos nigeria email smith",
|
||||
"_j =th of august = attn president ce",
|
||||
"o strictly private business proposal i am mr johns",
|
||||
"on s abu the bills and exchange director at the fo",
|
||||
"reignremittance department of the central bank of ",
|
||||
"nigeria i am writingyou this letter to ask for you",
|
||||
"r support and cooperation to carrying thisbusiness",
|
||||
" opportunity in my department we discovered abando",
|
||||
"ned the sumof us = = = = thirt",
|
||||
"y seven million four hundred thousand unitedstates",
|
||||
" dollars in an account that belong to one of our f",
|
||||
"oreign customers an american late engr john creek ",
|
||||
"junior an oil merchant with the federal government",
|
||||
" of nigeria who died along with his entire family ",
|
||||
"of a wifeand two children in kenya airbus a= ",
|
||||
"= flight kq= in november= since we ",
|
||||
"heard of his death we have been expecting his next",
|
||||
" of kin tocome over and put claims for his money a",
|
||||
"s the heir because we cannotrelease the fund from ",
|
||||
"his account unless someone applies for claims asth",
|
||||
"e next of kin to the deceased as indicated in our ",
|
||||
"banking guidelines unfortunately neither their fam",
|
||||
"ily member nor distant relative hasappeared to cla",
|
||||
"im the said fund upon this discovery i and other o",
|
||||
"fficialsin my department have agreed to make busin",
|
||||
"ess with you release the totalamount into your acc",
|
||||
"ount as the heir of the fund since no one came for",
|
||||
"it or discovered either maintained account with ou",
|
||||
"r bank other wisethe fund will be returned to the ",
|
||||
"bank treasury as unclaimed fund we have agreed tha",
|
||||
"t our ratio of sharing will be as stated thus NUMB",
|
||||
"ER for you as foreign partner and = for us th",
|
||||
"e officials in my department upon the successful c",
|
||||
"ompletion of this transfer my colleague and i will",
|
||||
"come to your country and mind our share it is from",
|
||||
" our = we intendto import computer accessorie",
|
||||
"s into my country as way of recycling thefund to c",
|
||||
"ommence this transaction we require you to immedia",
|
||||
"tely indicateyour interest by calling me or sendin",
|
||||
"g me a fax immediately on the abovetelefax and enc",
|
||||
"lose your private contact telephone fax full namea",
|
||||
"nd address and your designated banking co ordinate",
|
||||
"s to enable us fileletter of claim to the appropri",
|
||||
"ate department for necessary approvalsbefore the t",
|
||||
"ransfer can be made note also this transaction mus",
|
||||
"t be kept strictly confidential becauseof its natu",
|
||||
"re nb please remember to give me your phone and fa",
|
||||
"x no mr johnson smith abu irish linux users group ",
|
||||
"ilug for un subscription information list ",
|
||||
"maintainer listmaster \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: dear stuart\r\n\r\n are you tired of searching",
|
||||
" for love in all the wrong places find love now at",
|
||||
" browse through thousands of personals in ",
|
||||
"your area join for free search e mail chat use",
|
||||
" to meet cool guys and hot girls go = on ",
|
||||
"= or use our private chat rooms click on the ",
|
||||
"link to get started find love now you have rec",
|
||||
"eived this email because you have registerd with e",
|
||||
"mailrewardz or subscribed through one of our marke",
|
||||
"ting partners if you have received this message in",
|
||||
" error or wish to stop receiving these great offer",
|
||||
"s please click the remove link above to unsubscrib",
|
||||
"e from these mailings please click here \r\n\r\n"
|
||||
),
|
||||
];
|
||||
|
||||
pub const HAM: [&str; 10] = [
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: i have been",
|
||||
" trying to research via sa mirrors and search engi",
|
||||
"nes\r\n\r\nif a canned script exists giving clients acce",
|
||||
"ss to their user_prefs options via a web based cgi",
|
||||
" interface numerous isps provide this feature to c",
|
||||
"lients but so far i can find nothing our configura",
|
||||
"tion uses amavis postfix and clamav for virus filt",
|
||||
"ering and procmail with spamassassin for spam filt",
|
||||
"ering i would prefer not to have to write a script",
|
||||
" myself but will appreciate any suggestions this U",
|
||||
"RL email is sponsored by osdn tired of that same o",
|
||||
"ld cell phone get a new here for free ________",
|
||||
"_______________________________________ spamassass",
|
||||
"in talk mailing list spamassassin talk \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: [email protected]\r\nSubject: hello\r\n\r\nhave y",
|
||||
"ou seen and discussed this article and his approac",
|
||||
"h thank you hell there are no rules here we re",
|
||||
" trying to accomplish something thomas alva edison",
|
||||
" this email is sponsored by osdn tired of that",
|
||||
" same old cell phone get a new here for free _",
|
||||
"______________________________________________ spa",
|
||||
"massassin devel mailing list spamassassin devel UR",
|
||||
"L \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: hi all apol",
|
||||
"ogies for the possible silly question\r\n\r\ni don t thi",
|
||||
"nk it is but but is eircom s adsl service nat ed a",
|
||||
"nd what implications would that have for voip i kn",
|
||||
"ow there are difficulties with voip or connecting ",
|
||||
"to clients connected to a nat ed network from the ",
|
||||
"internet wild i e machines with static real ips an",
|
||||
"y help pointers would be helpful cheers rgrds bern",
|
||||
"ard bernard tyers national centre for sensor resea",
|
||||
"rch p = = = = e bernard tyers ",
|
||||
" w l n= ______________________________",
|
||||
"_________________ iiu mailing list iiu \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: can someone",
|
||||
" explain\r\n\r\nwhat type of operating system solaris is",
|
||||
" as ive never seen or used it i dont know wheather",
|
||||
" to get a server from sun or from dell i would pre",
|
||||
"fer a linux based server and sun seems to be the o",
|
||||
"ne for that but im not sure if solaris is a distro",
|
||||
" of linux or a completely different operating syst",
|
||||
"em can someone explain kiall mac innes irish linux",
|
||||
" users group ilug for un subscription info",
|
||||
"rmation list maintainer listmaster \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: folks my fi",
|
||||
"rst time posting\r\n\r\nhave a bit of unix experience bu",
|
||||
"t am new to linux just got a new pc at home dell b",
|
||||
"ox with windows xp added a second hard disk for li",
|
||||
"nux partitioned the disk and have installed suse N",
|
||||
"UMBER = from cd which went fine except it did",
|
||||
"n t pick up my monitor i have a dell branded eNUMB",
|
||||
"ERfpp = lcd flat panel monitor and a nvidia g",
|
||||
"eforce= ti= video card both of which are",
|
||||
" probably too new to feature in suse s default set",
|
||||
" i downloaded a driver from the nvidia website and",
|
||||
" installed it using rpm then i ran sax= as wa",
|
||||
"s recommended in some postings i found on the net ",
|
||||
"but it still doesn t feature my video card in the ",
|
||||
"available list what next another problem i have a ",
|
||||
"dell branded keyboard and if i hit caps lock twice",
|
||||
" the whole machine crashes in linux not windows ev",
|
||||
"en the on off switch is inactive leaving me to rea",
|
||||
"ch for the power cable instead if anyone can help ",
|
||||
"me in any way with these probs i d be really grate",
|
||||
"ful i ve searched the net but have run out of idea",
|
||||
"s or should i be going for a different version of ",
|
||||
"linux such as redhat opinions welcome thanks a lot",
|
||||
" peter irish linux users group ilug for un",
|
||||
" subscription information list maintainer listmast",
|
||||
"er \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: has anyone\r\n",
|
||||
"\r\nseen heard of used some package that would let a ",
|
||||
"random person go to a webpage create a mailing lis",
|
||||
"t then administer that list also of course let ppl",
|
||||
" sign up for the lists and manage their subscripti",
|
||||
"ons similar to the old but i d like to have it",
|
||||
" running on my server not someone elses chris ",
|
||||
"\r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: hi thank yo",
|
||||
"u for the useful replies\r\n\r\ni have found some intere",
|
||||
"sting tutorials in the ibm developer connection UR",
|
||||
"L and registration is needed i will post the s",
|
||||
"ame message on the web application security list a",
|
||||
"s suggested by someone for now i thing i will use ",
|
||||
"md= for password checking i will use the appr",
|
||||
"oach described in secure programmin fo linux and u",
|
||||
"nix how to i will separate the authentication modu",
|
||||
"le so i can change its implementation at anytime t",
|
||||
"hank you again mario torre please avoid sending me",
|
||||
" word or powerpoint attachments see \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: hehe sorry\r\n",
|
||||
"\r\nbut if you hit caps lock twice the computer crash",
|
||||
"es theres one ive never heard before have you trye",
|
||||
"d dell support yet i think dell computers prefer r",
|
||||
"edhat dell provide some computers pre loaded with ",
|
||||
"red hat i dont know for sure tho so get someone el",
|
||||
"ses opnion as well as mine original message from i",
|
||||
"lug admin mailto ilug admin on behalf of p",
|
||||
"eter staunton sent = august = = NUM",
|
||||
"BER to ilug subject ilug newbie seeks advice s",
|
||||
"use = = folks my first time posting have",
|
||||
" a bit of unix experience but am new to linux just",
|
||||
" got a new pc at home dell box with windows xp add",
|
||||
"ed a second hard disk for linux partitioned the di",
|
||||
"sk and have installed suse = = from cd w",
|
||||
"hich went fine except it didn t pick up my monitor",
|
||||
" i have a dell branded e=fpp = lcd flat ",
|
||||
"panel monitor and a nvidia geforce= ti= ",
|
||||
"video card both of which are probably too new to f",
|
||||
"eature in suse s default set i downloaded a driver",
|
||||
" from the nvidia website and installed it using rp",
|
||||
"m then i ran sax= as was recommended in some ",
|
||||
"postings i found on the net but it still doesn t f",
|
||||
"eature my video card in the available list what ne",
|
||||
"xt another problem i have a dell branded keyboard ",
|
||||
"and if i hit caps lock twice the whole machine cra",
|
||||
"shes in linux not windows even the on off switch i",
|
||||
"s inactive leaving me to reach for the power cable",
|
||||
" instead if anyone can help me in any way with the",
|
||||
"se probs i d be really grateful i ve searched the ",
|
||||
"net but have run out of ideas or should i be going",
|
||||
" for a different version of linux such as redhat o",
|
||||
"pinions welcome thanks a lot peter irish linux use",
|
||||
"rs group ilug for un subscription informat",
|
||||
"ion list maintainer listmaster irish linux use",
|
||||
"rs group ilug for un subscription informat",
|
||||
"ion list maintainer listmaster \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: it will fun",
|
||||
"ction as a router\r\n\r\nif that is what you wish it eve",
|
||||
"n looks like the modem s embedded os is some kind ",
|
||||
"of linux being that it has interesting interfaces ",
|
||||
"like eth= i don t use it as a router though i",
|
||||
" just have it do the absolute minimum dsl stuff an",
|
||||
"d do all the really fun stuff like pppoe on my lin",
|
||||
"ux box also the manual tells you what the default ",
|
||||
"password is don t forget to run pppoe over the alc",
|
||||
"atel speedtouch =i as in my case you have to ",
|
||||
"have a bridge configured in the router modem s sof",
|
||||
"tware this lists your vci values etc also does any",
|
||||
"one know if the high end speedtouch with = et",
|
||||
"hernet ports can act as a full router or do i stil",
|
||||
"l need to run a pppoe stack on the linux box regar",
|
||||
"ds vin irish linux users group ilug for un",
|
||||
" subscription information list maintainer listmast",
|
||||
"er irish linux users group ilug for un",
|
||||
" subscription information list maintainer listmast",
|
||||
"er \r\n\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Message-ID: <[email protected]>\r\nSubject: all is it ",
|
||||
"just me\r\n\r\nor has there been a massive increase in t",
|
||||
"he amount of email being falsely bounced around th",
|
||||
"e place i ve already received email from a number ",
|
||||
"of people i don t know asking why i am sending the",
|
||||
"m email these can be explained by servers from rus",
|
||||
"sia and elsewhere coupled with the false emails i ",
|
||||
"received myself it s really starting to annoy me a",
|
||||
"m i the only one seeing an increase in recent week",
|
||||
"s martin martin whelan déise design tel NUMBE",
|
||||
"R = our core product déiseditor allows organ",
|
||||
"isations to publish information to their web site ",
|
||||
"in a fast and cost effective manner there is no ne",
|
||||
"ed for a full time web developer as the site can b",
|
||||
"e easily updated by the organisations own staff in",
|
||||
"stant updates to keep site information fresh sites",
|
||||
" which are updated regularly bring users back visi",
|
||||
"t for a demonstration déiseditor managing you",
|
||||
"r information ____________________________________",
|
||||
"___________ iiu mailing list iiu ,0\r\n"
|
||||
),
|
||||
];
|
||||
|
||||
pub const TEST: [&str; 3] = [
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"Subject: save up to = on life insurance\r\n\r\nwhy ",
|
||||
"spend more than you have to life quote savings ens",
|
||||
"uring your family s financial security is very imp",
|
||||
"ortant life quote savings makes buying life insura",
|
||||
"nce simple and affordable we provide free access t",
|
||||
"o the very best companies and the lowest rates lif",
|
||||
"e quote savings is fast easy and saves you money l",
|
||||
"et us help you get started with the best values in",
|
||||
" the country on new coverage you can save hundreds",
|
||||
" or even thousands of dollars by requesting a free",
|
||||
" quote from lifequote savings our service will tak",
|
||||
"e you less than = minutes to complete shop an",
|
||||
"d compare save up to = on all types of life i",
|
||||
"nsurance hyperlink click here for your free quote ",
|
||||
"protecting your family is the best investment you ",
|
||||
"ll ever make if you are in receipt of this email i",
|
||||
"n error and or wish to be removed from our list hy",
|
||||
"perlink please click here and type remove if you r",
|
||||
"eside in any state which prohibits e mail solicita",
|
||||
"tions for insurance please disregard this email\r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: can someone explain\r\n\r\nwhat type of operati",
|
||||
"ng system solaris is as ive never seen or used it ",
|
||||
"i dont know wheather to get a server from sun or f",
|
||||
"rom dell i would prefer a linux based server and s",
|
||||
"un seems to be the one for that but im not sure if",
|
||||
" solaris is a distro of linux or a completely diff",
|
||||
"erent operating system can someone explain kiall m",
|
||||
"ac innes irish linux users group ilug for ",
|
||||
"un subscription information list maintainer listma",
|
||||
"ster \r\n"
|
||||
),
|
||||
concat!(
|
||||
"Subject: classifier test\r\n\r\nthis is a novel text tha",
|
||||
"t the sgd classifier has never seen before, it s",
|
||||
"hould be classified as ham or non-ham\r\n"
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||
use common::auth::credential::{ApiKey, AppPassword};
|
||||
use jmap_proto::error::set::SetErrorType;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::StorageQuota,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
self, Account, Credential, Http, PasswordCredential, SecondaryCredential, UserAccount,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, datetime::UTCDateTime, ipmask::IpAddrOrMask, list::List, map::Map},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running Authentication tests...");
|
||||
|
||||
let admin = test.account("[email protected]");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
|
||||
// Enable X-Forwarded-For processing to test IP-based access restrictions
|
||||
admin
|
||||
.registry_update_setting(
|
||||
Http {
|
||||
use_x_forwarded: true,
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::UseXForwarded],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().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("[email protected]", "this is a very strong password", true).await;
|
||||
validate_password("[email protected]", "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("[email protected]", "this is a very strong password", false).await;
|
||||
validate_password("[email protected]", "very strong password indeed", true).await;
|
||||
|
||||
// Set password expiration in two seconds and verify it works
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
"credentials/0/expiresAt": UTCDateTime::from_timestamp((now() + 2) as i64)
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let mut user = crate::utils::account::Account::new(
|
||||
"[email protected]",
|
||||
"very strong password indeed",
|
||||
&[],
|
||||
"User",
|
||||
user_id,
|
||||
);
|
||||
user.registry_query_ids(
|
||||
ObjectType::PublicKey,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
assert_eq!(
|
||||
user.registry_query(
|
||||
ObjectType::PublicKey,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
.method_response()
|
||||
.text_field("type"),
|
||||
"forbidden"
|
||||
);
|
||||
|
||||
// Password updates should require the old password
|
||||
user.registry_update_object_expect_err(
|
||||
ObjectType::AccountPassword,
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::Secret: "12345"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::Forbidden)
|
||||
.assert_description_contains(
|
||||
"Current secret must be provided to change the password or OTP auth.",
|
||||
);
|
||||
|
||||
// Password policies should be enforced when changing password
|
||||
user.registry_update_object_expect_err(
|
||||
ObjectType::AccountPassword,
|
||||
Id::singleton(),
|
||||
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.");
|
||||
|
||||
// Perform a valid password update
|
||||
user.registry_update_object(
|
||||
ObjectType::AccountPassword,
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::CurrentSecret: "very strong password indeed",
|
||||
Property::Secret: "user provided strong password"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
validate_password("[email protected]", "very strong password indeed", false).await;
|
||||
validate_password("[email protected]", "user provided strong password", true).await;
|
||||
user.update_secret("user provided strong password");
|
||||
|
||||
// After a successful password change, the user permissions should be restored
|
||||
user.registry_query_ids(
|
||||
ObjectType::PublicKey,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Limit login to specific IPs and set credential quotas
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
"credentials/0/allowedIps": {"192.168.1.1": true},
|
||||
Property::Quotas: {
|
||||
StorageQuota::MaxApiKeys.as_str(): 1,
|
||||
StorageQuota::MaxAppPasswords.as_str(): 1,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
validate_password_with_ip(
|
||||
"[email protected]",
|
||||
"user provided strong password",
|
||||
"192.168.1.1",
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
validate_password_with_ip(
|
||||
"[email protected]",
|
||||
"user provided strong password",
|
||||
"192.168.1.2",
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
"credentials/0/allowedIps": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create an IP-restricted App Password and verify it works
|
||||
let response = user
|
||||
.registry_create([structs::AppPassword {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]),
|
||||
description: "My app password".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
.await;
|
||||
let app_password = response.created(0);
|
||||
let app_password_id = app_password.object_id();
|
||||
let app_password_secret = app_password.text_field("secret").to_string();
|
||||
let _ = AppPassword::parse(&app_password_secret).unwrap();
|
||||
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.2", true).await;
|
||||
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.3", false).await;
|
||||
|
||||
// Create an IP-restricted API key and verify it works
|
||||
let response = user
|
||||
.registry_create([structs::ApiKey {
|
||||
allowed_ips: Map::new(vec![IpAddrOrMask::from_str("10.0.0.2").unwrap()]),
|
||||
description: "My API key".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
.await;
|
||||
let api_key = response.created(0);
|
||||
let api_key_id = api_key.object_id();
|
||||
let api_key_secret = api_key.text_field("secret").to_string();
|
||||
let _ = ApiKey::parse(&api_key_secret).unwrap();
|
||||
validate_token_with_ip(&api_key_secret, "10.0.0.2", true).await;
|
||||
validate_token_with_ip(&api_key_secret, "10.0.0.3", false).await;
|
||||
|
||||
// Creating more API keys or app passwords should fail due to quota
|
||||
user.registry_create_object_expect_err(structs::AppPassword {
|
||||
description: "Another app password".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.assert_type(SetErrorType::OverQuota)
|
||||
.assert_description_contains("You have exceeded your quota of 1 app passwords.");
|
||||
user.registry_create_object_expect_err(structs::ApiKey {
|
||||
description: "Another API key".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.assert_type(SetErrorType::OverQuota)
|
||||
.assert_description_contains("You have exceeded your quota of 1 API keys.");
|
||||
|
||||
// Set a credential expiration in the past and verify it is rejected
|
||||
for (credential_id, object_type) in [
|
||||
(app_password_id, ObjectType::AppPassword),
|
||||
(api_key_id, ObjectType::ApiKey),
|
||||
] {
|
||||
user.registry_update_object(
|
||||
object_type,
|
||||
credential_id,
|
||||
json!({
|
||||
Property::ExpiresAt: UTCDateTime::now()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await;
|
||||
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.2", false).await;
|
||||
|
||||
// Destroy the API key and app password, then verify they no longer work
|
||||
for (credential_id, object_type) in [
|
||||
(app_password_id, ObjectType::AppPassword),
|
||||
(api_key_id, ObjectType::ApiKey),
|
||||
] {
|
||||
let response = user.registry_destroy(object_type, [credential_id]).await;
|
||||
assert_eq!(
|
||||
vec![credential_id],
|
||||
response.destroyed_ids().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
validate_token_with_ip(&api_key_secret, "10.0.0.2", false).await;
|
||||
validate_password_with_ip("[email protected]", &app_password_secret, "10.0.0.2", false).await;
|
||||
validate_password("[email protected]", "user provided strong password", true).await;
|
||||
|
||||
// Clean up
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_destroy(ObjectType::Account, [user_id])
|
||||
.await
|
||||
.destroyed_ids()
|
||||
.collect::<Vec<_>>(),
|
||||
vec![user_id]
|
||||
);
|
||||
validate_password("[email protected]", "user provided strong password", false).await;
|
||||
|
||||
// Disable X-Forwarded-For processing
|
||||
admin
|
||||
.registry_update_setting(
|
||||
Http {
|
||||
use_x_forwarded: false,
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::UseXForwarded],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
pub async fn validate_password(username: &str, password: &str, is_valid: bool) {
|
||||
validate_password_with_ip(username, password, "127.0.0.1", is_valid).await;
|
||||
}
|
||||
|
||||
pub async fn validate_password_with_ip(
|
||||
username: &str,
|
||||
password: &str,
|
||||
remote_ip: &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))
|
||||
.header("X-Forwarded-For", remote_ip)
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn validate_token_with_ip(token: &str, remote_ip: &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")
|
||||
.bearer_auth(token)
|
||||
.header("X-Forwarded-For", remote_ip)
|
||||
.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 token to be {}. Server responded with status {}: {}",
|
||||
if is_valid { "valid" } else { "invalid" },
|
||||
status,
|
||||
text
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||
use ahash::AHashMap;
|
||||
use common::auth::{BuildAccessToken, permissions::DefaultPermissions};
|
||||
use jmap_proto::error::set::SetErrorType;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::Permission,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
self, AccountSettings, Credential, CustomRoles, PasswordCredential, Role, UserAccount,
|
||||
UserRoles,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, list::List, map::Map},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use types::id::Id;
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Authorization tests...");
|
||||
|
||||
let admin = test.account("[email protected]");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
|
||||
// Create nested roles
|
||||
let l3_role_id = admin
|
||||
.registry_create_object(Role {
|
||||
description: "Level 3 role".to_string(),
|
||||
enabled_permissions: Map::new(vec![Permission::SysAccountSettingsGet]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let l2_role_id = admin
|
||||
.registry_create_object(Role {
|
||||
description: "Level 2 role".to_string(),
|
||||
enabled_permissions: Map::new(vec![
|
||||
Permission::AuthenticateWithAlias,
|
||||
Permission::SysAccountSettingsUpdate,
|
||||
]),
|
||||
role_ids: Map::new(vec![l3_role_id]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let l1_role_id = admin
|
||||
.registry_create_object(Role {
|
||||
description: "Level 1 role".to_string(),
|
||||
enabled_permissions: Map::new(vec![Permission::Authenticate]),
|
||||
role_ids: Map::new(vec![l2_role_id]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
// Create a user with the nested role
|
||||
let user_id = admin
|
||||
.registry_create_object(structs::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()
|
||||
})]),
|
||||
roles: UserRoles::Custom(CustomRoles {
|
||||
role_ids: Map::new(vec![l1_role_id]),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
let user = crate::utils::account::Account::new(
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"User",
|
||||
user_id,
|
||||
);
|
||||
|
||||
// Verify user permissions include all permissions from the nested roles
|
||||
user.registry_update_object(
|
||||
ObjectType::AccountSettings,
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::Description: "Updated description"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
user.registry_get::<AccountSettings>(Id::singleton())
|
||||
.await
|
||||
.description
|
||||
.as_deref(),
|
||||
Some("Updated description")
|
||||
);
|
||||
|
||||
// Remove read permissions from the l3 role and verify the user can no longer read account settings
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Role,
|
||||
l3_role_id,
|
||||
json!({
|
||||
Property::EnabledPermissions: {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
user.registry_get_many(ObjectType::AccountSettings, [Id::singleton()])
|
||||
.await
|
||||
.method_response()
|
||||
.text_field("type"),
|
||||
"forbidden"
|
||||
);
|
||||
|
||||
// User should still be able to update account settings due to permissions from the l2 role
|
||||
user.registry_update_object(
|
||||
ObjectType::AccountSettings,
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::Description: "Updated description v2"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Disable account settings update permission in the l3 role
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Role,
|
||||
l3_role_id,
|
||||
json!({
|
||||
Property::DisabledPermissions: Map::new(vec![Permission::SysAccountSettingsUpdate]),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
user.registry_update(
|
||||
ObjectType::AccountSettings,
|
||||
[(
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::Description: "Updated description v3"
|
||||
})
|
||||
)]
|
||||
)
|
||||
.await
|
||||
.method_response()
|
||||
.text_field("type"),
|
||||
"forbidden"
|
||||
);
|
||||
|
||||
// Assign user to the default user role
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
user_id,
|
||||
json!({
|
||||
Property::Roles: UserRoles::User
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Make sure the user does not have any administrator permissions
|
||||
let permissions = DefaultPermissions::default();
|
||||
let mut num_permissions_verified = 0;
|
||||
let mut num_objects_verified = 0;
|
||||
let user_access_token = test
|
||||
.server
|
||||
.access_token(user_id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.build();
|
||||
for permission in permissions.superuser {
|
||||
if permissions.user.contains(&permission) {
|
||||
continue;
|
||||
}
|
||||
num_permissions_verified += 1;
|
||||
assert!(
|
||||
!user_access_token.has_permission(permission),
|
||||
"User should not have {:?} permission",
|
||||
permission
|
||||
);
|
||||
|
||||
if let Some(name) = permission
|
||||
.as_str()
|
||||
.strip_prefix("sys")
|
||||
.and_then(|perm| perm.strip_suffix("Get"))
|
||||
{
|
||||
let object_type = ObjectType::parse(name).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
user.registry_get_many(object_type, Vec::<&str>::new())
|
||||
.await
|
||||
.method_response()
|
||||
.text_field("type"),
|
||||
"forbidden",
|
||||
"User should not have permission to read {:?} objects",
|
||||
object_type
|
||||
);
|
||||
|
||||
num_objects_verified += 1;
|
||||
}
|
||||
}
|
||||
assert_ne!(
|
||||
num_permissions_verified, 0,
|
||||
"No permissions were verified in the test"
|
||||
);
|
||||
assert_ne!(
|
||||
num_objects_verified, 0,
|
||||
"No object read permissions were verified in the test"
|
||||
);
|
||||
|
||||
// Deleting a linked role should not be allowed
|
||||
admin
|
||||
.registry_destroy_object_expect_err(ObjectType::Role, l2_role_id)
|
||||
.await
|
||||
.assert_type(SetErrorType::ObjectIsLinked);
|
||||
|
||||
// Delete the account and roles in the correct order
|
||||
admin.destroy_account(user).await;
|
||||
for role_id in [l1_role_id, l2_role_id, l3_role_id] {
|
||||
admin
|
||||
.registry_destroy(ObjectType::Role, [role_id])
|
||||
.await
|
||||
.assert_destroyed(&[role_id]);
|
||||
}
|
||||
|
||||
// Create test data for John and Jane
|
||||
let john = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is john's secret",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let jane = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is jane's secret",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let mut john_ids = AHashMap::new();
|
||||
let mut jane_ids = AHashMap::new();
|
||||
for (account, ids) in [(&john, &mut john_ids), (&jane, &mut jane_ids)] {
|
||||
let pk_id = account
|
||||
.registry_create_many(
|
||||
ObjectType::PublicKey,
|
||||
[json!({
|
||||
Property::Description:"This is a public key",
|
||||
Property::Key: SMIME_CERTIFICATE,
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.created(0)
|
||||
.object_id();
|
||||
ids.insert(ObjectType::PublicKey, pk_id);
|
||||
|
||||
let masked_id = account
|
||||
.registry_create_many(
|
||||
ObjectType::MaskedEmail,
|
||||
[json!({
|
||||
Property::EmailDomain: "example.org",
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.created(0)
|
||||
.object_id();
|
||||
ids.insert(ObjectType::MaskedEmail, masked_id);
|
||||
}
|
||||
|
||||
// John should not be able to see Jane's objects and vice versa
|
||||
for (account, own_ids, other_ids) in
|
||||
[(&john, &john_ids, &jane_ids), (&jane, &jane_ids, &john_ids)]
|
||||
{
|
||||
for (object_type, id) in own_ids {
|
||||
assert_eq!(
|
||||
account
|
||||
.registry_query(*object_type, Vec::<(&str, &str)>::new(), Vec::<&str>::new())
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>(),
|
||||
vec![*id]
|
||||
);
|
||||
assert_eq!(
|
||||
account
|
||||
.registry_get_many(*object_type, Vec::<&str>::new())
|
||||
.await
|
||||
.list()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
for (object_type, id) in other_ids {
|
||||
assert_eq!(
|
||||
account
|
||||
.registry_get_many(*object_type, [*id])
|
||||
.await
|
||||
.not_found()
|
||||
.map(|id| Id::from_str(id).unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![*id]
|
||||
);
|
||||
|
||||
account
|
||||
.registry_update_object_expect_err(
|
||||
*object_type,
|
||||
*id,
|
||||
json!({
|
||||
Property::Description: "Hacked description"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::NotFound);
|
||||
|
||||
account
|
||||
.registry_destroy_object_expect_err(*object_type, *id)
|
||||
.await
|
||||
.assert_type(SetErrorType::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
// Admin should see all objects
|
||||
for object_type in [ObjectType::PublicKey, ObjectType::MaskedEmail] {
|
||||
let objects = admin
|
||||
.registry_query(object_type, Vec::<(&str, &str)>::new(), Vec::<&str>::new())
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(objects.len(), 2);
|
||||
assert!(
|
||||
objects.contains(&john_ids[&object_type]) && objects.contains(&jane_ids[&object_type]),
|
||||
);
|
||||
|
||||
// Filter by account id should work
|
||||
let objects = admin
|
||||
.registry_query(
|
||||
object_type,
|
||||
[(Property::AccountId, john.id().to_string())],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(objects, vec![john_ids[&object_type]]);
|
||||
}
|
||||
|
||||
// Destroy test data
|
||||
for (account, ids) in [(&john, &john_ids), (&jane, &jane_ids)] {
|
||||
for (object_type, id) in ids {
|
||||
account
|
||||
.registry_destroy(*object_type, [*id])
|
||||
.await
|
||||
.assert_destroyed(&[*id]);
|
||||
}
|
||||
}
|
||||
admin.destroy_account(john).await;
|
||||
admin.destroy_account(jane).await;
|
||||
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
const SMIME_CERTIFICATE: &str = "-----BEGIN CERTIFICATE-----
|
||||
MIIDbjCCAlagAwIBAgIUZ4K0WXNSS8H0cUcZavD9EYqqTAswDQYJKoZIhvcNAQEN
|
||||
BQAwLTErMCkGA1UEAxMiU2FtcGxlIExBTVBTIENlcnRpZmljYXRlIEF1dGhvcml0
|
||||
eTAgFw0xOTExMjAwNjU0MThaGA8yMDUyMDkyNzA2NTQxOFowGTEXMBUGA1UEAxMO
|
||||
QWxpY2UgTG92ZWxhY2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDD
|
||||
7q35ZdG2JAzzJGNZDZ9sV7AKh0hlRfoFjTZN5m4RegQAYSyag43ouWi1xRN0avf0
|
||||
UTYrwjK04qRdV7GzCACoEKq/xiNUOsjfJXzbCublN3fZMOXDshKKBqThlK75SjA9
|
||||
Czxg7ejGoiY/iidk0e91neK30SCCaBTJlfR2ZDrPk73IPMeksxoTatfF9hw9dDA+
|
||||
/Hi1yptN/aG0Q/s9icFrxr6y2zQXsjuQPmjMZgj10aD9cazWVgRYCgflhmA0V1uQ
|
||||
l1wobYU8DAVxVn+GgabqyjGQMoythIK0Gn5+ofwxXXUM/zbU+g6+1ISdoXxRRFtq
|
||||
2GzbIqkAHZZQm+BbnFrhAgMBAAGjgZcwgZQwDAYDVR0TAQH/BAIwADAeBgNVHREE
|
||||
FzAVgRNhbGljZUBzbWltZS5leGFtcGxlMBMGA1UdJQQMMAoGCCsGAQUFBwMEMA8G
|
||||
A1UdDwEB/wQFAwMHoAAwHQYDVR0OBBYEFKwuVFqk/VUYry7oZkQ40SXR1wB5MB8G
|
||||
A1UdIwQYMBaAFLdSTXPAiD2yw3paDPOU9/eAonfbMA0GCSqGSIb3DQEBDQUAA4IB
|
||||
AQB76o4Yz7yrVSFcpXqLrcGtdI4q93aKCXECCCzNQLp4yesh6brqaZHNJtwYcJ5T
|
||||
qbUym9hJ70iJE4jGNN+yAZR1ltte0HFKYIBKM4EJumG++2hqbUaLz4tl06BHaQPC
|
||||
v/9NiNY7q9R9c/B6s1YzHhwqkWht2a+AtgJ4BkpG+g+MmZMQV/Ao7RwLFKJ9OlMW
|
||||
LBmEXFcpIJN0HpPasT0nEl/MmotSu+8RnClAi3yFfyTKb+8rD7VxuyXetqDZ6dU/
|
||||
9/iqD/SZS7OQIjywtd343mACz3B1RlFxMHSA6dQAf2btGumqR0KiAp3KkYRAePoa
|
||||
JqYkB7Zad06ngFl0G0FHON+7
|
||||
-----END CERTIFICATE-----
|
||||
";
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{jmap::JmapUtils, server::TestServer, smtp::SmtpConnection};
|
||||
use common::{
|
||||
auth::{
|
||||
ACCOUNT_FLAG_ENCRYPT_ALGO_AES128, ACCOUNT_FLAG_ENCRYPT_ALGO_AES256,
|
||||
ACCOUNT_FLAG_ENCRYPT_METHOD_PGP, ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME,
|
||||
},
|
||||
storage::encryption::{EncryptionMethod, parse_public_key},
|
||||
};
|
||||
use email::message::crypto::EncryptMessage;
|
||||
use mail_parser::{MessageParser, MimeHeaders};
|
||||
use registry::schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{EncryptionAtRest, EncryptionSettings, PublicKey},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use types::id::Id;
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Encryption-at-rest tests...");
|
||||
|
||||
// Check encryption
|
||||
check_is_encrypted();
|
||||
import_certs_and_encrypt().await;
|
||||
|
||||
// Create test account
|
||||
let account = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let client = account.jmap_client().await;
|
||||
|
||||
// Import all certs
|
||||
let mut cert_ids = Vec::new();
|
||||
let mut certs_parsed = Vec::new();
|
||||
for cert_file in ["cert_smime.pem", "cert_pgp.pem"] {
|
||||
let certs = std::fs::read_to_string(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("crypto")
|
||||
.join(cert_file),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let params = parse_public_key(&PublicKey {
|
||||
description: cert_file.to_string(),
|
||||
key: certs.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
certs_parsed.push(params.certs);
|
||||
|
||||
let cert_id = account
|
||||
.registry_create_many(
|
||||
ObjectType::PublicKey,
|
||||
[json!({
|
||||
Property::Description: "This is a public key",
|
||||
Property::Key: certs
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.created(0)
|
||||
.object_id();
|
||||
|
||||
cert_ids.push(cert_id);
|
||||
}
|
||||
|
||||
// Update encryption at rest settings
|
||||
account
|
||||
.registry_update_object(
|
||||
ObjectType::AccountSettings,
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::EncryptionAtRest: EncryptionAtRest::Aes256(EncryptionSettings {
|
||||
allow_spam_training: true,
|
||||
encrypt_on_append: true,
|
||||
public_key: cert_ids[1],
|
||||
})
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
test.server
|
||||
.account(account.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.encryption_key
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&certs_parsed[1]
|
||||
);
|
||||
|
||||
// Send a new message, which should be encrypted
|
||||
let mut lmtp = SmtpConnection::connect().await;
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: TPS Report (should be encrypted)\r\n",
|
||||
"\r\n",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send an encrypted message
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: TPS Report (already encrypted)\r\n",
|
||||
"Content-Type: application/pkcs7-mime; name=\"smime.p7m\"; smime-type=enveloped-data\r\n",
|
||||
"\r\n",
|
||||
"xjMEZMYfNhYJKwYBBAHaRw8BAQdAYyTN1HzqapLw8xwkCGwa0OjsgT/JqhcB/+Dy",
|
||||
"Ga1fsBrNG0pvaG4gRG9lIDxqb2huQGV4YW1wbGUub3JnPsKJBBMWCAAxFiEEg836",
|
||||
"pwbXpuQ/THMtpJwd4oBfIrUFAmTGHzYCGwMECwkIBwUVCAkKCwUWAgMBAAAKCRCk",
|
||||
"nB3igF8itYhyAQD2jEdeYa3gyQ47X9YWZTK1wEJkN8W9//V1fYl2XQwqlQEA0qBv",
|
||||
"Ai6nUh99oDw+/zQ8DFIKdeb5Ti4tu/X58PdpiQ7OOARkxh82EgorBgEEAZdVAQUB",
|
||||
"AQdAvXz2FbFN0DovQF/ACnZyczTsSIQp0mvmF1PE+aijbC8DAQgHwngEGBYIACAW",
|
||||
"IQSDzfqnBtem5D9Mcy2knB3igF8itQUCZMYfNgIbDAAKCRCknB3igF8itRnoAQC3",
|
||||
"GzPmgx7TnB+SexPuJV/DoKSMJ0/X+hbEFcZkulxaDQEAh+xiJCvf+ZNAKw6kFhsL",
|
||||
"UuZhEDktxnY6Ehz3aB7FawA=",
|
||||
"=KGrr",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Disable encryption
|
||||
account
|
||||
.registry_update_object(
|
||||
ObjectType::AccountSettings,
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::EncryptionAtRest: EncryptionAtRest::Disabled
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send a new message, which should NOT be encrypted
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: TPS Report (plain text)\r\n",
|
||||
"\r\n",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Check messages
|
||||
let mut request = client.build();
|
||||
request.get_email();
|
||||
let emails = request.send_get_email().await.unwrap().take_list();
|
||||
assert_eq!(emails.len(), 3, "3 messages were expected: {:#?}.", emails);
|
||||
|
||||
for email in emails {
|
||||
let message =
|
||||
String::from_utf8(client.download(email.blob_id().unwrap()).await.unwrap()).unwrap();
|
||||
if message.contains("should be encrypted") {
|
||||
assert!(
|
||||
message.contains("Content-Type: multipart/encrypted"),
|
||||
"got message {message}, expected encrypted message"
|
||||
);
|
||||
} else if message.contains("already encrypted") {
|
||||
assert!(
|
||||
message.contains("Content-Type: application/pkcs7-mime")
|
||||
&& message.contains("xjMEZMYfNhYJKwYBBAHaRw8BAQdAYy"),
|
||||
"got message {message}, expected message to be left intact"
|
||||
);
|
||||
} else if message.contains("plain text") {
|
||||
assert!(
|
||||
message.contains("I'm going to need those TPS reports ASAP."),
|
||||
"got message {message}, expected plain text message"
|
||||
);
|
||||
} else {
|
||||
panic!("Unexpected message: {:#?}", message)
|
||||
}
|
||||
}
|
||||
|
||||
test.account("[email protected]")
|
||||
.destroy_account(account)
|
||||
.await;
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
pub async fn import_certs_and_encrypt() {
|
||||
for (name, method) in [
|
||||
("cert_pgp.pem", EncryptionMethod::PGP),
|
||||
//("cert_pgp.der", EncryptionMethod::PGP),
|
||||
("cert_smime.pem", EncryptionMethod::SMIME),
|
||||
//("cert_smime.der", EncryptionMethod::SMIME),
|
||||
] {
|
||||
let pk = PublicKey {
|
||||
description: name.to_string(),
|
||||
key: String::from_utf8(
|
||||
std::fs::read(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("crypto")
|
||||
.join(name),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let params = parse_public_key(&pk).unwrap().unwrap();
|
||||
assert_eq!(params.method, method);
|
||||
|
||||
for mut flags in [
|
||||
ACCOUNT_FLAG_ENCRYPT_ALGO_AES128,
|
||||
ACCOUNT_FLAG_ENCRYPT_ALGO_AES256,
|
||||
] {
|
||||
let message = MessageParser::new()
|
||||
.parse(b"Subject: test\r\ntest\r\n")
|
||||
.unwrap();
|
||||
assert!(!message.is_encrypted());
|
||||
flags |= match method {
|
||||
EncryptionMethod::PGP => ACCOUNT_FLAG_ENCRYPT_METHOD_PGP,
|
||||
EncryptionMethod::SMIME => ACCOUNT_FLAG_ENCRYPT_METHOD_SMIME,
|
||||
};
|
||||
message.encrypt(¶ms.certs, flags).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// S/MIME and PGP should not be allowed mixed
|
||||
assert!(
|
||||
parse_public_key(&PublicKey {
|
||||
description: "err".into(),
|
||||
key: String::from_utf8(
|
||||
std::fs::read(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("crypto")
|
||||
.join("cert_mixed.pem"),
|
||||
)
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
..Default::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
pub fn check_is_encrypted() {
|
||||
let messages = std::fs::read_to_string(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("crypto")
|
||||
.join("is_encrypted.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for raw_message in messages.split("!!!") {
|
||||
let is_encrypted = raw_message.contains("TRUE");
|
||||
let message = MessageParser::new()
|
||||
.parse(raw_message.trim().as_bytes())
|
||||
.unwrap();
|
||||
assert!(message.content_type().is_some());
|
||||
assert_eq!(
|
||||
message.is_encrypted(),
|
||||
is_encrypted,
|
||||
"failed for {raw_message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{
|
||||
account::Account, imap::AssertResult, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection,
|
||||
};
|
||||
use common::{Server, auth::BuildAccessToken};
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
mailbox::{INBOX_ID, JUNK_ID, SENT_ID},
|
||||
message::metadata::MessageMetadata,
|
||||
};
|
||||
use groupware::DavResourceName;
|
||||
use jmap::blob::download::BlobDownload;
|
||||
use jmap_proto::error::set::SetErrorType;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::StorageQuota,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
EmailAlias, Expression, MailingList, MtaExtensions, SpamTag, SpamTagScore,
|
||||
SpamTrainingSample,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, datetime::UTCDateTime, float::Float, list::List, map::Map},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use store::{
|
||||
ValueKey,
|
||||
roaring::RoaringBitmap,
|
||||
write::{AlignedBytes, Archive, now},
|
||||
};
|
||||
use types::{
|
||||
blob::{BlobClass, BlobId},
|
||||
collection::Collection,
|
||||
field::EmailField,
|
||||
id::Id,
|
||||
};
|
||||
use utils::chained_bytes::ChainedBytes;
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Email delivery tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
|
||||
// Prepare tests
|
||||
admin
|
||||
.registry_create_object(SpamTag::Score(SpamTagScore {
|
||||
score: Float::new(1000.0),
|
||||
tag: "GTUBE_TEST".to_string(),
|
||||
}))
|
||||
.await;
|
||||
admin
|
||||
.registry_update_setting(
|
||||
MtaExtensions {
|
||||
expn: Expression {
|
||||
else_: "true".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
vrfy: Expression {
|
||||
else_: "true".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::Expn, Property::Vrfy],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Create a domain name and a test account
|
||||
let john = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&["[email protected]"],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let jane = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let bill = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
john.id(),
|
||||
json!({
|
||||
Property::Quotas: {StorageQuota::MaxMaskedAddresses.as_str(): 2}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Create a mailing list
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
let list_id = admin
|
||||
.registry_create_object(MailingList {
|
||||
name: "members".to_string(),
|
||||
recipients: Map::new(vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
]),
|
||||
aliases: List::from_iter([EmailAlias {
|
||||
name: "corporate".to_string(),
|
||||
domain_id,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
}]),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
// Delivering to individuals
|
||||
let mut lmtp = SmtpConnection::connect().await;
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: TPS Report\r\n",
|
||||
"\r\n",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
let john_cache = test
|
||||
.server
|
||||
.get_cached_messages(john.id().document_id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(john_cache.emails.items.len(), 1);
|
||||
assert_eq!(john_cache.in_mailbox(INBOX_ID).count(), 1);
|
||||
assert_eq!(john_cache.in_mailbox(JUNK_ID).count(), 0);
|
||||
|
||||
// Make sure there are no spam training samples
|
||||
admin
|
||||
.registry_destroy_all(ObjectType::SpamTrainingSample)
|
||||
.await;
|
||||
assert!(
|
||||
admin
|
||||
.registry_query(
|
||||
ObjectType::SpamTrainingSample,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
.ids()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// Masked email tests
|
||||
john.registry_create_many(
|
||||
ObjectType::MaskedEmail,
|
||||
[json!({
|
||||
Property::EmailDomain: "invalid.org"
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.not_created(0)
|
||||
.to_set_error()
|
||||
.assert_type(SetErrorType::Forbidden)
|
||||
.assert_description_contains("The specified domain is not valid for this account.");
|
||||
|
||||
let response = john
|
||||
.registry_create_many(
|
||||
ObjectType::MaskedEmail,
|
||||
[json!({
|
||||
Property::EmailDomain: "example.org",
|
||||
Property::EmailPrefix: "secretive",
|
||||
Property::ExpiresAt: UTCDateTime::from_timestamp((now() + 1) as i64)
|
||||
})],
|
||||
)
|
||||
.await;
|
||||
let masked = response.created(0);
|
||||
let masked_prefix_id = masked.object_id();
|
||||
let masked_prefix_email = masked.text_field("email").to_string();
|
||||
assert!(
|
||||
masked_prefix_email.starts_with("secretive")
|
||||
&& masked_prefix_email.ends_with("@example.org"),
|
||||
"Unexpected masked email: {masked_prefix_email}"
|
||||
);
|
||||
|
||||
let response = john
|
||||
.registry_create_many(
|
||||
ObjectType::MaskedEmail,
|
||||
[json!({
|
||||
Property::EmailDomain: "example.org",
|
||||
})],
|
||||
)
|
||||
.await;
|
||||
let masked = response.created(0);
|
||||
let masked_random_id = masked.object_id();
|
||||
let masked_random_email = masked.text_field("email").to_string();
|
||||
assert!(
|
||||
masked_random_email.contains(".") && masked_random_email.ends_with("@example.org"),
|
||||
"Unexpected masked email: {masked_random_email}"
|
||||
);
|
||||
|
||||
john.registry_create_many(
|
||||
ObjectType::MaskedEmail,
|
||||
[json!({
|
||||
Property::EmailDomain: "example.org",
|
||||
})],
|
||||
)
|
||||
.await
|
||||
.not_created(0)
|
||||
.to_set_error()
|
||||
.assert_type(SetErrorType::OverQuota);
|
||||
|
||||
// Test spam filtering using masked email
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&[masked_prefix_email.as_str()],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n",
|
||||
"\r\n",
|
||||
"--- Forwarded Message ---\r\n\r\n ",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let john_cache = test
|
||||
.server
|
||||
.get_cached_messages(john.id().document_id())
|
||||
.await
|
||||
.unwrap();
|
||||
let inbox_ids = john_cache
|
||||
.in_mailbox(INBOX_ID)
|
||||
.map(|e| e.document_id)
|
||||
.collect::<RoaringBitmap>();
|
||||
let junk_ids = john_cache
|
||||
.in_mailbox(JUNK_ID)
|
||||
.map(|e| e.document_id)
|
||||
.collect::<RoaringBitmap>();
|
||||
assert_eq!(john_cache.emails.items.len(), 2);
|
||||
assert_eq!(inbox_ids.len(), 1);
|
||||
assert_eq!(junk_ids.len(), 1);
|
||||
assert_message_headers_contains(
|
||||
&test.server,
|
||||
john.id().document_id(),
|
||||
junk_ids.min().unwrap(),
|
||||
"X-Spam-Status: Yes",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(john.spam_training_samples().await, vec![]);
|
||||
|
||||
// CardDAV spam override, using masked email
|
||||
let dav_client = john.webdav_client();
|
||||
dav_client
|
||||
.request(
|
||||
"PUT",
|
||||
&format!(
|
||||
"{}/jdoe%40example.org/default/bill.vcf",
|
||||
DavResourceName::Card.base_path()
|
||||
),
|
||||
r#"BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Bill Foobar
|
||||
EMAIL;TYPE=WORK:[email protected]
|
||||
UID:urn:uuid:e1ee798b-3d4c-41b0-b217-b9c918e4686f
|
||||
END:VCARD
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.with_status(hyper::StatusCode::CREATED);
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&[masked_random_email.as_str()],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n",
|
||||
"\r\n",
|
||||
"--- Forwarded Message ---\r\n\r\n ",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let john_cache = test
|
||||
.server
|
||||
.get_cached_messages(john.id().document_id())
|
||||
.await
|
||||
.unwrap();
|
||||
let inbox_ids = john_cache
|
||||
.in_mailbox(INBOX_ID)
|
||||
.map(|e| e.document_id)
|
||||
.collect::<RoaringBitmap>();
|
||||
let junk_ids = john_cache
|
||||
.in_mailbox(JUNK_ID)
|
||||
.map(|e| e.document_id)
|
||||
.collect::<RoaringBitmap>();
|
||||
assert_eq!(john_cache.emails.items.len(), 3);
|
||||
assert_eq!(inbox_ids.len(), 2);
|
||||
assert_eq!(junk_ids.len(), 1);
|
||||
dav_client.delete_default_containers().await;
|
||||
assert_message_headers_contains(
|
||||
&test.server,
|
||||
john.id().document_id(),
|
||||
inbox_ids.max().unwrap(),
|
||||
"X-Spam-Status: No, reason=card-exists",
|
||||
)
|
||||
.await;
|
||||
let samples = john.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 1);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 0);
|
||||
|
||||
// Test trusted reply override
|
||||
john.jmap_client()
|
||||
.await
|
||||
.email_import(
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Message-ID: <[email protected]>\r\n",
|
||||
"Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n",
|
||||
"\r\n",
|
||||
"This is a trusted reply."
|
||||
)
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
vec![Id::from(SENT_ID).to_string()],
|
||||
None::<Vec<String>>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id();
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(john.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Message-ID: <[email protected]>\r\n",
|
||||
"References: <[email protected]>\r\n",
|
||||
"Subject: XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X\r\n",
|
||||
"\r\n",
|
||||
"--- Forwarded Message ---\r\n\r\n ",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let john_cache = test
|
||||
.server
|
||||
.get_cached_messages(john.id().document_id())
|
||||
.await
|
||||
.unwrap();
|
||||
let inbox_ids = john_cache
|
||||
.in_mailbox(INBOX_ID)
|
||||
.map(|e| e.document_id)
|
||||
.collect::<RoaringBitmap>();
|
||||
let junk_ids = john_cache
|
||||
.in_mailbox(JUNK_ID)
|
||||
.map(|e| e.document_id)
|
||||
.collect::<RoaringBitmap>();
|
||||
assert_eq!(john_cache.emails.items.len(), 5);
|
||||
assert_eq!(inbox_ids.len(), 3);
|
||||
assert_eq!(junk_ids.len(), 1);
|
||||
assert_message_headers_contains(
|
||||
&test.server,
|
||||
john.id().document_id(),
|
||||
inbox_ids.max().unwrap(),
|
||||
"X-Spam-Status: No, reason=trusted-reply",
|
||||
)
|
||||
.await;
|
||||
let samples = john.spam_training_samples().await;
|
||||
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 2);
|
||||
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 0);
|
||||
|
||||
// EXPN and VRFY
|
||||
lmtp.expn("[email protected]", 2)
|
||||
.await
|
||||
.assert_contains("[email protected]")
|
||||
.assert_contains("[email protected]")
|
||||
.assert_contains("[email protected]");
|
||||
lmtp.expn("[email protected]", 5).await;
|
||||
lmtp.expn("[email protected]", 5).await;
|
||||
lmtp.vrfy("[email protected]", 2).await;
|
||||
lmtp.vrfy("[email protected]", 5).await;
|
||||
lmtp.vrfy("[email protected]", 5).await;
|
||||
lmtp.vrfy(masked_random_email.as_str(), 2).await;
|
||||
lmtp.vrfy(masked_prefix_email.as_str(), 5).await; // Should have expired
|
||||
|
||||
// Delivering to a mailing list
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: WFH policy\r\n",
|
||||
"\r\n",
|
||||
"We need the entire staff back in the office, ",
|
||||
"TPS reports cannot be filed properly from home."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
for (account, num_messages) in [(&john, 6), (&jane, 1), (&bill, 1)] {
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(account.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len(),
|
||||
num_messages,
|
||||
"for {}",
|
||||
account.id_string()
|
||||
);
|
||||
}
|
||||
|
||||
// Removing members from the mailing list and chunked ingest
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::MailingList,
|
||||
list_id,
|
||||
json!({
|
||||
"recipients/[email protected]": false
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
lmtp.ingest_chunked(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: WFH policy (reminder)\r\n",
|
||||
"\r\n",
|
||||
"This is a reminder that we need the entire staff back in the office, ",
|
||||
"TPS reports cannot be filed properly from home."
|
||||
),
|
||||
10,
|
||||
)
|
||||
.await;
|
||||
|
||||
for (account, num_messages) in [(&john, 6), (&jane, 2), (&bill, 2)] {
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(account.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len(),
|
||||
num_messages,
|
||||
"for {}",
|
||||
account.id_string()
|
||||
);
|
||||
}
|
||||
|
||||
// Deduplication of recipients
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&[
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"Bcc: Undisclosed recipients;\r\n",
|
||||
"Subject: Holidays\r\n",
|
||||
"\r\n",
|
||||
"Remember to file your TPS reports before ",
|
||||
"going on holidays."
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Make sure blobs are properly linked
|
||||
test.blob_expire_all().await;
|
||||
|
||||
for (account, num_messages) in [(&john, 7), (&jane, 3), (&bill, 3)] {
|
||||
let account_id = account.id().document_id();
|
||||
let cache = test.server.get_cached_messages(account_id).await.unwrap();
|
||||
assert_eq!(
|
||||
cache.emails.items.len(),
|
||||
num_messages,
|
||||
"for {}",
|
||||
account.id_string()
|
||||
);
|
||||
let access_token = test.server.access_token(account_id).await.unwrap().build();
|
||||
|
||||
for document_id in cache.in_mailbox(INBOX_ID).map(|e| e.document_id) {
|
||||
let metadata = message_metadata(&test.server, account_id, document_id).await;
|
||||
let partial_message = test
|
||||
.server
|
||||
.blob_store()
|
||||
.get_blob(metadata.blob_hash.0.as_ref(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(metadata.blob_body_offset, 0);
|
||||
let expected_full_message = String::from_utf8(
|
||||
ChainedBytes::new(metadata.raw_headers.as_ref())
|
||||
.with_last(
|
||||
partial_message
|
||||
.get(metadata.blob_body_offset as usize..)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.to_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
expected_full_message.contains("Delivered-To:")
|
||||
&& expected_full_message.contains("Subject:"),
|
||||
"for {account_id}: {expected_full_message}"
|
||||
);
|
||||
let full_message = String::from_utf8(
|
||||
test.server
|
||||
.blob_download(
|
||||
&BlobId {
|
||||
hash: metadata.blob_hash,
|
||||
class: BlobClass::Linked {
|
||||
account_id,
|
||||
collection: Collection::Email.into(),
|
||||
document_id,
|
||||
},
|
||||
section: None,
|
||||
},
|
||||
&access_token,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(full_message, expected_full_message, "for {account_id}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-addressed local members must resolve when the list is expanded
|
||||
admin
|
||||
.registry_create_object(MailingList {
|
||||
name: "tps-reports".to_string(),
|
||||
recipients: Map::new(vec!["[email protected]".to_string()]),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let bill_messages = test
|
||||
.server
|
||||
.get_cached_messages(bill.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len();
|
||||
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: Cover sheet\r\n",
|
||||
"\r\n",
|
||||
"Did you get the memo about the new cover sheet?"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(bill.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len(),
|
||||
bill_messages + 1,
|
||||
"sub-addressed mailing list member was not delivered"
|
||||
);
|
||||
|
||||
// Remove test data
|
||||
john.registry_destroy(
|
||||
ObjectType::MaskedEmail,
|
||||
[masked_prefix_id, masked_random_id],
|
||||
)
|
||||
.await
|
||||
.assert_destroyed(&[masked_prefix_id, masked_random_id]);
|
||||
for account in [&john, &jane, &bill] {
|
||||
test.destroy_all_mailboxes(account).await;
|
||||
}
|
||||
admin.registry_destroy_all(ObjectType::MailingList).await;
|
||||
admin
|
||||
.registry_destroy_all(ObjectType::SpamTrainingSample)
|
||||
.await;
|
||||
admin.registry_destroy_all(ObjectType::SpamTag).await;
|
||||
test.assert_is_empty().await;
|
||||
|
||||
for account in [john, jane, bill] {
|
||||
admin.destroy_account(account).await;
|
||||
}
|
||||
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub async fn spam_training_sample_ids(&self) -> Vec<Id> {
|
||||
self.registry_query_ids(
|
||||
ObjectType::SpamTrainingSample,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn spam_training_samples(&self) -> Vec<(Id, SpamTrainingSample)> {
|
||||
self.registry_get_all().await
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_message_headers_contains(
|
||||
server: &Server,
|
||||
account_id: u32,
|
||||
document_id: u32,
|
||||
value: &str,
|
||||
) {
|
||||
let headers = message_headers(server, account_id, document_id).await;
|
||||
assert!(
|
||||
headers.contains(value),
|
||||
"Expected message headers to contain {:?}, got {:?}",
|
||||
value,
|
||||
headers
|
||||
);
|
||||
}
|
||||
|
||||
async fn message_headers(server: &Server, account_id: u32, document_id: u32) -> String {
|
||||
std::str::from_utf8(
|
||||
message_metadata(server, account_id, document_id)
|
||||
.await
|
||||
.raw_headers
|
||||
.as_ref(),
|
||||
)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn message_metadata(server: &Server, account_id: u32, document_id: u32) -> MessageMetadata {
|
||||
server
|
||||
.store()
|
||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||
account_id,
|
||||
Collection::Email,
|
||||
document_id,
|
||||
EmailField::Metadata,
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.deserialize::<MessageMetadata>()
|
||||
.unwrap()
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||
use common::{
|
||||
auth::{ACCOUNT_IS_USER, EmailAddress, EmailCache},
|
||||
network::RcptResolution,
|
||||
};
|
||||
use jmap_proto::error::set::SetErrorType;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{AccountType, StorageQuota},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
Account, CertificateManagement, Credential, DkimManagement, DnsManagement, Domain,
|
||||
EmailAlias, Expression, ExpressionMatch, GroupAccount, MailingList, PasswordCredential,
|
||||
SubAddressing, SubAddressingCustom, UserAccount,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, list::List, map::Map},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub async fn test(test: &TestServer) {
|
||||
println!("Running Directory tests...");
|
||||
let account = test.account("[email protected]");
|
||||
|
||||
// Create a domain and make sure it's in the cache
|
||||
let domain_id = account
|
||||
.registry_create_object(Domain {
|
||||
name: "example.com".to_string(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
aliases: Map::new(vec!["beispiel.de".to_string()]),
|
||||
is_enabled: true,
|
||||
catch_all_address: Some("[email protected]".to_string()),
|
||||
sub_addressing: SubAddressing::Enabled,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let domain_cache = test
|
||||
.server
|
||||
.domain_by_id(domain_id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&domain_cache.names,
|
||||
&Box::from_iter(["example.com".into(), "beispiel.de".into()])
|
||||
);
|
||||
assert_eq!(domain_cache.id, domain_id.document_id());
|
||||
assert_eq!(
|
||||
domain_cache.catch_all.as_deref(),
|
||||
Some("[email protected]")
|
||||
);
|
||||
|
||||
// Multiple domains with the same name should not be allowed
|
||||
account
|
||||
.registry_create_object_expect_err(Domain {
|
||||
name: "example.com".to_string(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
|
||||
// An alias matching another domain's name should not be allowed
|
||||
account
|
||||
.registry_create_object_expect_err(Domain {
|
||||
name: "example.net".to_string(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
aliases: Map::new(vec!["example.com".to_string()]),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
|
||||
// An alias matching another domain's alias should not be allowed
|
||||
account
|
||||
.registry_create_object_expect_err(Domain {
|
||||
name: "example.net".to_string(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
aliases: Map::new(vec!["beispiel.de".to_string()]),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
|
||||
// A domain name matching another domain's alias should not be allowed
|
||||
account
|
||||
.registry_create_object_expect_err(Domain {
|
||||
name: "beispiel.de".to_string(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
|
||||
// Invalid local part should not be allowed
|
||||
account
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
name: "!invalid".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "hello world".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
aliases: List::from_iter([EmailAlias {
|
||||
name: "inva..lid".to_string(),
|
||||
domain_id,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidPatch)
|
||||
.assert_description_contains("Invalid email local part");
|
||||
|
||||
// Valid account creation with local part sanitization
|
||||
let account_id = account
|
||||
.registry_create_object(Account::User(UserAccount {
|
||||
name: " john doe".to_string(),
|
||||
domain_id,
|
||||
description: "John 'Johnny-D' Doe".to_string().into(),
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "hello world".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
aliases: List::from_iter([EmailAlias {
|
||||
name: "jdoe".to_string(),
|
||||
domain_id,
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
}]),
|
||||
quotas: VecMap::from_iter([
|
||||
(StorageQuota::MaxDiskQuota, 1024u64),
|
||||
(StorageQuota::MaxEmails, 100u64),
|
||||
]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
let account_cache = test.server.account(account_id.document_id()).await.unwrap();
|
||||
assert_eq!(account_cache.name.as_ref(), "[email protected]");
|
||||
assert_eq!(
|
||||
account_cache.description.as_deref(),
|
||||
Some("John 'Johnny-D' Doe")
|
||||
);
|
||||
assert_eq!(account_cache.id, account_id.document_id());
|
||||
assert_eq!(account_cache.quota_disk, 1024);
|
||||
assert_eq!(
|
||||
account_cache
|
||||
.quota_objects
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get(StorageQuota::MaxEmails),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
account_cache.addresses,
|
||||
vec![
|
||||
EmailAddress {
|
||||
local_part: "johndoe".into(),
|
||||
domain_id: domain_id.document_id(),
|
||||
},
|
||||
EmailAddress {
|
||||
local_part: "jdoe".into(),
|
||||
domain_id: domain_id.document_id(),
|
||||
}
|
||||
]
|
||||
.into_boxed_slice()
|
||||
);
|
||||
assert!(account_cache.flags & ACCOUNT_IS_USER != 0);
|
||||
|
||||
// Duplicate account names should not be allowed
|
||||
account
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
name: "johndoe".to_string(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
account
|
||||
.registry_create_object_expect_err(Account::User(UserAccount {
|
||||
name: "jdoe".to_string(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
account
|
||||
.registry_create_object_expect_err(Account::Group(GroupAccount {
|
||||
name: "jdoe".to_string(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
account
|
||||
.registry_create_object_expect_err(MailingList {
|
||||
name: "jdoe".to_string(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.assert_type(SetErrorType::PrimaryKeyViolation);
|
||||
|
||||
// Create a group and add it to the account
|
||||
let group_id = account
|
||||
.registry_create_object(Account::Group(GroupAccount {
|
||||
name: "sales".to_string(),
|
||||
domain_id,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
let account_cache = test.server.account(group_id.document_id()).await.unwrap();
|
||||
assert_eq!(account_cache.name.as_ref(), "[email protected]");
|
||||
assert!(account_cache.flags & ACCOUNT_IS_USER == 0);
|
||||
account
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
account_id,
|
||||
json!({
|
||||
Property::MemberGroupIds: {
|
||||
group_id: true
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let account_cache = test.server.account(account_id.document_id()).await.unwrap();
|
||||
assert_eq!(
|
||||
account_cache.id_member_of.as_ref(),
|
||||
&[group_id.document_id()]
|
||||
);
|
||||
|
||||
// Linking invalid groups should not be allowed
|
||||
account
|
||||
.registry_update_object_expect_err(
|
||||
ObjectType::Account,
|
||||
account_id,
|
||||
json!({
|
||||
Property::MemberGroupIds: {
|
||||
account_id: true
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_type(SetErrorType::InvalidForeignKey);
|
||||
|
||||
// Remove the group membership and make sure it's gone
|
||||
account
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
account_id,
|
||||
json!({
|
||||
Property::MemberGroupIds: {
|
||||
group_id: false
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let account_cache = test.server.account(account_id.document_id()).await.unwrap();
|
||||
assert!(account_cache.id_member_of.as_ref().is_empty());
|
||||
|
||||
// Create a masked email
|
||||
let john = crate::utils::account::Account::new(
|
||||
"[email protected]",
|
||||
"hello world",
|
||||
&[],
|
||||
"John",
|
||||
account_id,
|
||||
);
|
||||
let response = john
|
||||
.registry_create_many(
|
||||
ObjectType::MaskedEmail,
|
||||
[json!({
|
||||
Property::EmailPrefix: "test",
|
||||
})],
|
||||
)
|
||||
.await;
|
||||
let masked = response.created(0);
|
||||
let masked_id = masked.object_id();
|
||||
let masked_email = masked.text_field("email").to_string();
|
||||
assert_eq!(
|
||||
test.server
|
||||
.account_id_from_email("[email protected]", true)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(account_id.document_id())
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.account_id_from_email(&masked_email, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some(account_id.document_id())
|
||||
);
|
||||
|
||||
// Create a mailing list
|
||||
let list_id = account
|
||||
.registry_create_object(MailingList {
|
||||
name: "newsletter".to_string(),
|
||||
domain_id,
|
||||
recipients: Map::new(vec!["[email protected]".to_string()]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let list_cache = test
|
||||
.server
|
||||
.try_list(list_id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&list_cache.recipients,
|
||||
&Arc::from(Box::from_iter(["[email protected]".into()]))
|
||||
);
|
||||
|
||||
// Update mailing list
|
||||
account
|
||||
.registry_update_object(
|
||||
ObjectType::MailingList,
|
||||
list_id,
|
||||
json!({
|
||||
"recipients/[email protected]": true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let list_cache = test
|
||||
.server
|
||||
.try_list(list_id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&list_cache.recipients,
|
||||
&Arc::from(Box::from_iter([
|
||||
"[email protected]".into(),
|
||||
"[email protected]".into()
|
||||
]))
|
||||
);
|
||||
|
||||
// Verify that alias domains resolve without the primary name warming the cache
|
||||
for address in [
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
] {
|
||||
test.server.invalidate_all_local_caches();
|
||||
assert!(
|
||||
test.server.domain("beispiel.de").await.unwrap().is_some(),
|
||||
"Alias domain failed to resolve on a cold cache"
|
||||
);
|
||||
|
||||
test.server.invalidate_all_local_caches();
|
||||
assert!(
|
||||
test.server
|
||||
.rcpt_id_from_email(address)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"Cold cache resolution failed for {address}"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify RCPT expansion
|
||||
for (address, expected) in [
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::Account(account_id.document_id()),
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::Account(account_id.document_id()),
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::Account(account_id.document_id()),
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::Account(account_id.document_id()),
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::Account(group_id.document_id()),
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::Account(group_id.document_id()),
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::MailingList(list_id.document_id()),
|
||||
),
|
||||
(
|
||||
"[email protected]",
|
||||
EmailCache::MailingList(list_id.document_id()),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
test.server.rcpt_id_from_email(address).await.unwrap(),
|
||||
Some(expected),
|
||||
"Unexpected result for address: {address}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_id_from_email("[email protected]")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_id_from_email("[email protected]")
|
||||
.await
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
|
||||
// MTA rcpt resolve
|
||||
let domain_2_id = account
|
||||
.registry_create_object(Domain {
|
||||
name: "another-example.com".to_string(),
|
||||
is_enabled: true,
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
sub_addressing: SubAddressing::Custom(SubAddressingCustom {
|
||||
custom_rule: Expression {
|
||||
else_: "false".to_string(),
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "matches('^([^.]+)\\.([^.]+)', rcpt)".to_string(),
|
||||
then: "$1".to_string(),
|
||||
}]),
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let account_2_id = account
|
||||
.registry_create_object(Account::User(UserAccount {
|
||||
name: "subaddresser".to_string(),
|
||||
domain_id: domain_2_id,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
test.server.rcpt_resolve("unknown", true, 0).await.unwrap(),
|
||||
RcptResolution::UnknownDomain
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::UnknownDomain
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Accept
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Accept
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Accept
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Rewrite("[email protected]".into())
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Expand(Arc::from(Box::from_iter([
|
||||
"[email protected]".into(),
|
||||
"[email protected]".into()
|
||||
])))
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Rewrite("[email protected]".into())
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", false, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::UnknownRecipient
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", false, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Rewrite("[email protected]".into())
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Rewrite("[email protected]".into())
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::UnknownRecipient
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve(masked_email.as_str(), true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Rewrite("[email protected]".into())
|
||||
);
|
||||
|
||||
// Catch-all addresses have to be resolved rather than accepted verbatim
|
||||
let domain_3_id = account
|
||||
.registry_create_object(Domain {
|
||||
name: "list-catch-all.com".to_string(),
|
||||
is_enabled: true,
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
catch_all_address: Some("[email protected]".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let domain_4_id = account
|
||||
.registry_create_object(Domain {
|
||||
name: "subaddress-catch-all.com".to_string(),
|
||||
is_enabled: true,
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
catch_all_address: Some("[email protected]".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Expand(Arc::from(Box::from_iter([
|
||||
"[email protected]".into(),
|
||||
"[email protected]".into()
|
||||
])))
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", true, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::Rewrite("[email protected]".into())
|
||||
);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.rcpt_resolve("[email protected]", false, 0)
|
||||
.await
|
||||
.unwrap(),
|
||||
RcptResolution::UnknownRecipient
|
||||
);
|
||||
|
||||
// Query tests
|
||||
assert_eq!(
|
||||
account
|
||||
.registry_query_ids(
|
||||
ObjectType::Domain,
|
||||
[(Property::Name, "example.com")],
|
||||
[Property::Name]
|
||||
)
|
||||
.await,
|
||||
vec![domain_id]
|
||||
);
|
||||
assert_eq!(
|
||||
account
|
||||
.registry_query_ids(
|
||||
ObjectType::Account,
|
||||
[
|
||||
(Property::Name, "johndoe"),
|
||||
(Property::Type, AccountType::User.as_str()),
|
||||
(Property::Text, "johnny")
|
||||
],
|
||||
[Property::Name]
|
||||
)
|
||||
.await,
|
||||
vec![account_id]
|
||||
);
|
||||
|
||||
// Delete everything
|
||||
john.registry_destroy(ObjectType::MaskedEmail, [masked_id])
|
||||
.await
|
||||
.assert_destroyed(&[masked_id]);
|
||||
account
|
||||
.registry_destroy(ObjectType::MailingList, [list_id])
|
||||
.await
|
||||
.assert_destroyed(&[list_id]);
|
||||
account
|
||||
.registry_destroy(ObjectType::Account, [group_id, account_id, account_2_id])
|
||||
.await
|
||||
.assert_destroyed(&[group_id, account_id, account_2_id]);
|
||||
account
|
||||
.registry_destroy(
|
||||
ObjectType::Domain,
|
||||
[domain_id, domain_2_id, domain_3_id, domain_4_id],
|
||||
)
|
||||
.await
|
||||
.assert_destroyed(&[domain_id, domain_2_id, domain_3_id, domain_4_id]);
|
||||
assert!(
|
||||
test.server
|
||||
.try_list(list_id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
test.server
|
||||
.try_account(account_id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
test.server
|
||||
.try_account(group_id.document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(test.server.domain("example.com").await.unwrap().is_none());
|
||||
assert!(
|
||||
test.server
|
||||
.domain("another-example.com")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
test.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod antispam;
|
||||
pub mod authentication;
|
||||
pub mod authorization;
|
||||
pub mod crypto;
|
||||
pub mod delivery;
|
||||
pub mod directory;
|
||||
pub mod oidc;
|
||||
pub mod purge;
|
||||
pub mod quota;
|
||||
pub mod security;
|
||||
pub mod task;
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use registry::schema::structs::{Expression, Imap, MtaStageAuth};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn system_tests() {
|
||||
let mut test = TestServerBuilder::new("system_tests")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.with_object(Imap {
|
||||
allow_plain_text_auth: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.with_object(MtaStageAuth {
|
||||
require: Expression {
|
||||
else_: "false".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
|
||||
// Create admin account
|
||||
let admin = test
|
||||
.create_user_account(
|
||||
"admin",
|
||||
"[email protected]",
|
||||
"these_pretzels_are_making_me_thirsty",
|
||||
&[],
|
||||
"Admin",
|
||||
)
|
||||
.await;
|
||||
test.account("admin")
|
||||
.assign_roles_to_account(admin.id(), &["user", "system"])
|
||||
.await;
|
||||
test.insert_account(admin);
|
||||
|
||||
directory::test(&test).await;
|
||||
authentication::test(&test).await;
|
||||
oidc::test(&mut test).await;
|
||||
authorization::test(&mut test).await;
|
||||
tenant::test(&mut test).await;
|
||||
security::test(&mut test).await;
|
||||
quota::test(&mut test).await;
|
||||
purge::test(&mut test).await;
|
||||
delivery::test(&mut test).await;
|
||||
crypto::test(&mut test).await;
|
||||
antispam::test(&mut test).await;
|
||||
archiving::test(&mut test).await;
|
||||
task::test(&mut test).await;
|
||||
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{
|
||||
imap::{AssertResult, ImapConnection, Type},
|
||||
server::TestServer,
|
||||
};
|
||||
use ahash::AHashSet;
|
||||
use common::Server;
|
||||
use email::{
|
||||
cache::{MessageCacheFetch, email::MessageCacheAccess},
|
||||
mailbox::{INBOX_ID, JUNK_ID, TRASH_ID},
|
||||
};
|
||||
use imap_proto::ResponseType;
|
||||
use registry::schema::{
|
||||
enums::{TaskAccountMaintenanceType, TaskStoreMaintenanceType},
|
||||
prelude::Property,
|
||||
structs::{
|
||||
DataRetention, SpamClassifier, Task, TaskAccountMaintenance, TaskStatus,
|
||||
TaskStoreMaintenance,
|
||||
},
|
||||
};
|
||||
use store::{IterateParams, LogKey, U32_LEN, U64_LEN, write::key::DeserializeBigEndian};
|
||||
use types::id::Id;
|
||||
|
||||
const EXPUNGE_TRASH_AFTER: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Account purge tests...");
|
||||
let inbox_id = Id::from(INBOX_ID).to_string();
|
||||
let trash_id = Id::from(TRASH_ID).to_string();
|
||||
let junk_id = Id::from(JUNK_ID).to_string();
|
||||
let admin = test.account("[email protected]");
|
||||
|
||||
// Set test settings
|
||||
admin
|
||||
.registry_update_setting(
|
||||
DataRetention {
|
||||
max_changes_history: Some(1),
|
||||
expunge_trash_after: Some(EXPUNGE_TRASH_AFTER.into()),
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::MaxChangesHistory, Property::ExpungeTrashAfter],
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_setting(
|
||||
SpamClassifier {
|
||||
hold_samples_for: 1u64.into(),
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::HoldSamplesFor],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Create test account
|
||||
let account = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let client = account.jmap_client().await;
|
||||
|
||||
let mut imap = ImapConnection::connect(b"_x ").await;
|
||||
imap.assert_read(Type::Untagged, ResponseType::Ok).await;
|
||||
imap.authenticate("[email protected]", "this is a very strong password")
|
||||
.await;
|
||||
imap.send("STATUS INBOX (UIDNEXT MESSAGES UNSEEN)").await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::Ok)
|
||||
.await
|
||||
.assert_contains("MESSAGES 0");
|
||||
|
||||
// Create test messages
|
||||
let mut message_ids = Vec::new();
|
||||
let mut pass = 0;
|
||||
let mut changes = AHashSet::new();
|
||||
|
||||
loop {
|
||||
pass += 1;
|
||||
for folder_id in [&inbox_id, &trash_id, &junk_id] {
|
||||
message_ids.push(
|
||||
client
|
||||
.email_import(
|
||||
format!(
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: TPS Report #{} {}\r\n",
|
||||
"\r\n",
|
||||
"I'm going to need those TPS reports ASAP. ",
|
||||
"So, if you could do that, that'd be great."
|
||||
),
|
||||
pass, folder_id
|
||||
)
|
||||
.into_bytes(),
|
||||
[folder_id],
|
||||
None::<Vec<&str>>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id(),
|
||||
);
|
||||
}
|
||||
|
||||
if pass == 1 {
|
||||
let (changes_, is_truncated) = get_changes(&test.server).await;
|
||||
assert!(!is_truncated);
|
||||
changes = changes_;
|
||||
tokio::time::sleep(EXPUNGE_TRASH_AFTER + std::time::Duration::from_secs(1)).await;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check IMAP status
|
||||
imap.send("LIST \"\" \"*\" RETURN (STATUS (MESSAGES))")
|
||||
.await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::Ok)
|
||||
.await
|
||||
.assert_contains("\"INBOX\" (MESSAGES 2)")
|
||||
.assert_contains("\"Deleted Items\" (MESSAGES 2)")
|
||||
.assert_contains("\"Junk Mail\" (MESSAGES 2)");
|
||||
|
||||
// Make sure both messages and changes are present
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(account.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len(),
|
||||
6
|
||||
);
|
||||
|
||||
// Purge junk/trash messages and old changes
|
||||
admin
|
||||
.registry_create_object(Task::AccountMaintenance(TaskAccountMaintenance {
|
||||
account_id: account.id(),
|
||||
maintenance_type: TaskAccountMaintenanceType::Purge,
|
||||
status: TaskStatus::now(),
|
||||
}))
|
||||
.await;
|
||||
test.wait_for_tasks().await;
|
||||
let cache = test
|
||||
.server
|
||||
.get_cached_messages(account.id().document_id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Only 4 messages should remain
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(account.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
assert_eq!(cache.in_mailbox(INBOX_ID).count(), 2);
|
||||
assert_eq!(cache.in_mailbox(TRASH_ID).count(), 1);
|
||||
assert_eq!(cache.in_mailbox(JUNK_ID).count(), 1);
|
||||
|
||||
// Check IMAP status
|
||||
imap.send("LIST \"\" \"*\" RETURN (STATUS (MESSAGES))")
|
||||
.await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::Ok)
|
||||
.await
|
||||
.assert_contains("\"INBOX\" (MESSAGES 2)")
|
||||
.assert_contains("\"Deleted Items\" (MESSAGES 1)")
|
||||
.assert_contains("\"Junk Mail\" (MESSAGES 1)");
|
||||
|
||||
// Compare changes
|
||||
let (new_changes, is_truncated) = get_changes(&test.server).await;
|
||||
assert!(!changes.is_empty());
|
||||
assert!(!new_changes.is_empty());
|
||||
assert!(is_truncated);
|
||||
for change in &changes {
|
||||
assert!(
|
||||
!new_changes.contains(change),
|
||||
"Change {change:?} was not purged, expected {} changes, got {}",
|
||||
changes.len(),
|
||||
new_changes.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Delete expired training samples
|
||||
admin
|
||||
.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
|
||||
maintenance_type: TaskStoreMaintenanceType::PurgeBlob,
|
||||
shard_index: None,
|
||||
status: TaskStatus::now(),
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Delete account
|
||||
admin.destroy_account(account).await;
|
||||
test.wait_for_tasks().await;
|
||||
test.assert_is_empty().await;
|
||||
|
||||
// Reset settings
|
||||
admin
|
||||
.registry_update_setting(SpamClassifier::default(), &[Property::HoldSamplesFor])
|
||||
.await;
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
async fn get_changes(server: &Server) -> (AHashSet<(u64, u8)>, bool) {
|
||||
let mut changes = AHashSet::new();
|
||||
let mut is_truncated = false;
|
||||
server
|
||||
.core
|
||||
.storage
|
||||
.data
|
||||
.iterate(
|
||||
IterateParams::new(
|
||||
LogKey {
|
||||
account_id: 0,
|
||||
collection: 0,
|
||||
change_id: 0,
|
||||
},
|
||||
LogKey {
|
||||
account_id: u32::MAX,
|
||||
collection: u8::MAX,
|
||||
change_id: u64::MAX,
|
||||
},
|
||||
)
|
||||
.ascending(),
|
||||
|key, value| {
|
||||
if !value.is_empty() {
|
||||
changes.insert((
|
||||
key.deserialize_be_u64(key.len() - U64_LEN).unwrap(),
|
||||
key[U32_LEN],
|
||||
));
|
||||
} else {
|
||||
is_truncated = true;
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(changes, is_truncated)
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{
|
||||
account::Account, http::HttpRequest, jmap::JmapUtils, server::TestServer, smtp::SmtpConnection,
|
||||
};
|
||||
use email::{cache::MessageCacheFetch, mailbox::INBOX_ID};
|
||||
use jmap::blob::upload::DISABLE_UPLOAD_QUOTA;
|
||||
use jmap_client::{
|
||||
core::set::{SetErrorType, SetObject},
|
||||
email::EmailBodyPart,
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{Permission, StorageQuota, TaskAccountMaintenanceType},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
self, Credential, Jmap, PasswordCredential, PermissionsList, Task,
|
||||
TaskAccountMaintenance, TaskStatus, UserAccount,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, list::List, map::Map},
|
||||
};
|
||||
use serde_json::json;
|
||||
use types::id::Id;
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Quota tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
|
||||
// Set test settings
|
||||
admin
|
||||
.registry_update_setting(
|
||||
Jmap {
|
||||
upload_quota: 50000,
|
||||
max_upload_count: 3,
|
||||
upload_ttl: registry::types::duration::Duration::from_millis(1000),
|
||||
..Default::default()
|
||||
},
|
||||
&[
|
||||
Property::UploadQuota,
|
||||
Property::MaxUploadCount,
|
||||
Property::UploadTtl,
|
||||
],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Create test accounts
|
||||
let account_id = admin
|
||||
.registry_create_object(structs::Account::User(UserAccount {
|
||||
name: "user1".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "this is a very strong password1".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
quotas: VecMap::from_iter([(StorageQuota::MaxDiskQuota, 1024)]),
|
||||
permissions: structs::Permissions::Merge(PermissionsList {
|
||||
enabled_permissions: Map::new(vec![Permission::Impersonate]),
|
||||
disabled_permissions: Default::default(),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
let other_account_id = admin
|
||||
.registry_create_object(structs::Account::User(UserAccount {
|
||||
name: "user2".to_string(),
|
||||
domain_id,
|
||||
credentials: List::from_iter([Credential::Password(PasswordCredential {
|
||||
secret: "this is a very strong password2".to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
permissions: structs::Permissions::Merge(PermissionsList {
|
||||
enabled_permissions: Map::new(vec![Permission::Impersonate]),
|
||||
disabled_permissions: Default::default(),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
let account = Account::new(
|
||||
"[email protected]",
|
||||
"this is a very strong password1",
|
||||
&[],
|
||||
"User1",
|
||||
account_id,
|
||||
);
|
||||
let other_account = Account::new(
|
||||
"[email protected]",
|
||||
"this is a very strong password2",
|
||||
&[],
|
||||
"User2",
|
||||
other_account_id,
|
||||
);
|
||||
|
||||
// Delete temporary blobs from previous tests
|
||||
test.blob_expire_all().await;
|
||||
|
||||
// Test temporary blob quota (3 files)
|
||||
DISABLE_UPLOAD_QUOTA.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let client = account.jmap_client().await;
|
||||
let raw_http =
|
||||
HttpRequest::with_credentials(8899, "[email protected]", "this is a very strong password1");
|
||||
let upload_url = format!("/jmap/upload/{account_id}");
|
||||
for i in 0..3 {
|
||||
assert_eq!(
|
||||
client
|
||||
.upload(None, vec![b'A' + i; 1024], None)
|
||||
.await
|
||||
.unwrap()
|
||||
.size(),
|
||||
1024
|
||||
);
|
||||
}
|
||||
let resp = raw_http
|
||||
.send_full(
|
||||
hyper::Method::POST,
|
||||
&upload_url,
|
||||
Some(vec![b'Z'; 1024]),
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status.as_u16(),
|
||||
429,
|
||||
"blob-files-quota body: {}",
|
||||
resp.body
|
||||
);
|
||||
let policy = resp
|
||||
.rate_limit_policy()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers));
|
||||
assert!(
|
||||
policy.contains("\"blob-upload-files\";q=3"),
|
||||
"RateLimit-Policy = {policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains("\"blob-upload-bytes\";q=50000")
|
||||
&& policy.contains(r#"qu="content-bytes""#),
|
||||
"RateLimit-Policy = {policy}"
|
||||
);
|
||||
let state = resp
|
||||
.rate_limit()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers));
|
||||
assert!(
|
||||
state.contains("\"blob-upload-files\";r=0") && state.contains("t="),
|
||||
"RateLimit = {state}"
|
||||
);
|
||||
assert!(
|
||||
resp.retry_after().is_some(),
|
||||
"missing Retry-After on {:?}",
|
||||
resp.headers
|
||||
);
|
||||
assert!(resp.body.contains("quota"), "body = {}", resp.body);
|
||||
test.blob_expire_all().await;
|
||||
|
||||
// Test temporary blob quota (50000 bytes)
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
for i in 0..2 {
|
||||
assert_eq!(
|
||||
client
|
||||
.upload(None, vec![b'a' + i; 25000], None)
|
||||
.await
|
||||
.unwrap()
|
||||
.size(),
|
||||
25000
|
||||
);
|
||||
}
|
||||
let resp = raw_http
|
||||
.send_full(
|
||||
hyper::Method::POST,
|
||||
&upload_url,
|
||||
Some(vec![b'z'; 1024]),
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status.as_u16(),
|
||||
429,
|
||||
"blob-bytes-quota body: {}",
|
||||
resp.body
|
||||
);
|
||||
let policy = resp
|
||||
.rate_limit_policy()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit-Policy on {:?}", resp.headers));
|
||||
assert!(
|
||||
policy.contains("\"blob-upload-bytes\";q=50000")
|
||||
&& policy.contains(r#"qu="content-bytes""#),
|
||||
"RateLimit-Policy = {policy}"
|
||||
);
|
||||
let state = resp
|
||||
.rate_limit()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit on {:?}", resp.headers));
|
||||
assert!(
|
||||
state.contains("\"blob-upload-bytes\";r=0") && state.contains("t="),
|
||||
"RateLimit = {state}"
|
||||
);
|
||||
assert!(
|
||||
resp.retry_after().is_some(),
|
||||
"missing Retry-After on {:?}",
|
||||
resp.headers
|
||||
);
|
||||
test.blob_expire_all().await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
|
||||
// Test JMAP Quotas extension
|
||||
let response = account
|
||||
.jmap_method_call(
|
||||
"Quota/get",
|
||||
json!({
|
||||
"accountId": account.id_string(),
|
||||
"ids": null
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.to_string();
|
||||
assert!(response.contains("\"used\":0"), "{}", response);
|
||||
assert!(response.contains("\"hardLimit\":1024"), "{}", response);
|
||||
assert!(response.contains("\"scope\":\"account\""), "{}", response);
|
||||
assert!(
|
||||
response.contains("\"name\":\"[email protected]\""),
|
||||
"{}",
|
||||
response
|
||||
);
|
||||
|
||||
// Test Email/import quota
|
||||
let inbox_id = Id::new(INBOX_ID as u64).to_string();
|
||||
let mut message_ids = Vec::new();
|
||||
for i in 0..2 {
|
||||
message_ids.push(
|
||||
client
|
||||
.email_import(
|
||||
create_message_with_size(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
&format!("Test {i}"),
|
||||
512,
|
||||
),
|
||||
vec![&inbox_id],
|
||||
None::<Vec<String>>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id(),
|
||||
);
|
||||
}
|
||||
|
||||
assert_over_quota(
|
||||
client
|
||||
.email_import(
|
||||
create_message_with_size("[email protected]", "[email protected]", "Test 3", 100),
|
||||
vec![&inbox_id],
|
||||
None::<Vec<String>>,
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
// Test JMAP Quotas extension
|
||||
let response = account
|
||||
.jmap_method_call(
|
||||
"Quota/get",
|
||||
json!({
|
||||
"accountId": account.id_string(),
|
||||
"ids": null
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.to_string();
|
||||
assert!(response.contains("\"used\":1024"), "{}", response);
|
||||
assert!(response.contains("\"hardLimit\":1024"), "{}", response);
|
||||
|
||||
// Test registry quota
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_get_many(ObjectType::Account, [account_id])
|
||||
.await
|
||||
.list()[0]
|
||||
.integer_field(Property::UsedDiskQuota.as_str()),
|
||||
1024
|
||||
);
|
||||
|
||||
// Delete messages and check available quota
|
||||
test.wait_for_tasks().await;
|
||||
for message_id in message_ids {
|
||||
client.email_destroy(&message_id).await.unwrap();
|
||||
}
|
||||
|
||||
// Wait for pending index tasks
|
||||
test.wait_for_tasks().await;
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_used_quota_account(account.id().document_id())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
// Test Email/set quota
|
||||
let mut message_ids = Vec::new();
|
||||
for i in 0..2 {
|
||||
let mut request = client.build();
|
||||
let create_item = request.set_email().create();
|
||||
create_item
|
||||
.mailbox_ids([&inbox_id])
|
||||
.subject(format!("Test {i}"))
|
||||
.from(["[email protected]"])
|
||||
.to(["[email protected]"])
|
||||
.body_value("a".to_string(), String::from_utf8(vec![b'A'; 200]).unwrap())
|
||||
.text_body(EmailBodyPart::new().part_id("a"));
|
||||
let create_id = create_item.create_id().unwrap();
|
||||
message_ids.push(
|
||||
request
|
||||
.send_set_email()
|
||||
.await
|
||||
.unwrap()
|
||||
.created(&create_id)
|
||||
.unwrap()
|
||||
.take_id(),
|
||||
);
|
||||
}
|
||||
let mut request = client.build();
|
||||
let create_item = request.set_email().create();
|
||||
create_item
|
||||
.mailbox_ids([&inbox_id])
|
||||
.subject("Test 3")
|
||||
.from(["[email protected]"])
|
||||
.to(["[email protected]"])
|
||||
.body_value("a".to_string(), String::from_utf8(vec![b'A'; 400]).unwrap())
|
||||
.text_body(EmailBodyPart::new().part_id("a"));
|
||||
let create_id = create_item.create_id().unwrap();
|
||||
assert_over_quota(request.send_set_email().await.unwrap().created(&create_id));
|
||||
|
||||
// Recalculate quota
|
||||
let prev_quota = test
|
||||
.server
|
||||
.get_used_quota_account(account.id().document_id())
|
||||
.await
|
||||
.unwrap();
|
||||
admin
|
||||
.registry_create_object(Task::AccountMaintenance(TaskAccountMaintenance {
|
||||
account_id,
|
||||
maintenance_type: TaskAccountMaintenanceType::RecalculateQuota,
|
||||
status: TaskStatus::now(),
|
||||
}))
|
||||
.await;
|
||||
test.wait_for_tasks().await;
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_used_quota_account(account.id().document_id())
|
||||
.await
|
||||
.unwrap(),
|
||||
prev_quota
|
||||
);
|
||||
|
||||
// Delete messages and check available quota
|
||||
test.wait_for_tasks().await;
|
||||
for message_id in message_ids {
|
||||
client.email_destroy(&message_id).await.unwrap();
|
||||
}
|
||||
// Wait for pending index tasks
|
||||
test.wait_for_tasks().await;
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_used_quota_account(account.id().document_id())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
// Test Email/copy quota
|
||||
let other_client = other_account.jmap_client().await;
|
||||
let mut other_message_ids = Vec::new();
|
||||
let mut message_ids = Vec::new();
|
||||
for i in 0..3 {
|
||||
other_message_ids.push(
|
||||
other_client
|
||||
.email_import(
|
||||
create_message_with_size(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
&format!("Other Test {i}"),
|
||||
512,
|
||||
),
|
||||
vec![&inbox_id],
|
||||
None::<Vec<String>>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id(),
|
||||
);
|
||||
}
|
||||
for id in other_message_ids.iter().take(2) {
|
||||
message_ids.push(
|
||||
client
|
||||
.email_copy(
|
||||
other_account.id_string(),
|
||||
id,
|
||||
vec![&inbox_id],
|
||||
None::<Vec<String>>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.take_id(),
|
||||
);
|
||||
}
|
||||
assert_over_quota(
|
||||
client
|
||||
.email_copy(
|
||||
other_account.id_string(),
|
||||
&other_message_ids[2],
|
||||
vec![&inbox_id],
|
||||
None::<Vec<String>>,
|
||||
None,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
// Delete messages and check available quota
|
||||
test.wait_for_tasks().await;
|
||||
for message_id in message_ids {
|
||||
client.email_destroy(&message_id).await.unwrap();
|
||||
}
|
||||
// Wait for pending index tasks
|
||||
test.wait_for_tasks().await;
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_used_quota_account(account.id().document_id())
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
// Test delivery quota
|
||||
let mut lmtp = SmtpConnection::connect().await;
|
||||
for i in 0..2 {
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
&String::from_utf8(create_message_with_size(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
&format!("Ingest test {i}"),
|
||||
500,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let quota = test
|
||||
.server
|
||||
.get_used_quota_account(account.id().document_id())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(quota > 0 && quota <= 1024, "Quota is {}", quota);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.get_cached_messages(account.id().document_id())
|
||||
.await
|
||||
.unwrap()
|
||||
.emails
|
||||
.items
|
||||
.len(),
|
||||
1,
|
||||
);
|
||||
|
||||
DISABLE_UPLOAD_QUOTA.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
// Remove test data
|
||||
test.wait_for_tasks().await;
|
||||
test.destroy_all_mailboxes(&account).await;
|
||||
test.destroy_all_mailboxes(&other_account).await;
|
||||
admin.registry_destroy_all(ObjectType::QueuedMessage).await;
|
||||
admin
|
||||
.registry_destroy_all(ObjectType::SpamTrainingSample)
|
||||
.await;
|
||||
test.assert_is_empty().await;
|
||||
|
||||
admin
|
||||
.registry_destroy(ObjectType::Account, [account_id, other_account_id])
|
||||
.await
|
||||
.assert_destroyed(&[account_id, other_account_id]);
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
fn assert_over_quota<T: std::fmt::Debug>(result: Result<T, jmap_client::Error>) {
|
||||
match result {
|
||||
Ok(result) => panic!("Expected error, got {:?}", result),
|
||||
Err(jmap_client::Error::Set(err)) if err.error() == &SetErrorType::OverQuota => (),
|
||||
Err(err) => panic!("Expected OverQuota SetError, got {:?}", err),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_message_with_size(from: &str, to: &str, subject: &str, size: usize) -> Vec<u8> {
|
||||
let mut message = format!(
|
||||
"From: {}\r\nTo: {}\r\nSubject: {}\r\n\r\n",
|
||||
from, to, subject
|
||||
);
|
||||
for _ in 0..size - message.len() {
|
||||
message.push('A');
|
||||
}
|
||||
|
||||
message.into_bytes()
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
system::authentication::validate_password_with_ip,
|
||||
utils::{
|
||||
http::HttpRequest,
|
||||
imap::{ImapConnection, Type},
|
||||
registry::UnwrapRegistryId,
|
||||
server::TestServer,
|
||||
},
|
||||
};
|
||||
use common::ipc::RegistryChange;
|
||||
use imap_proto::ResponseType;
|
||||
use jmap_client::{
|
||||
client::{Client, Credentials},
|
||||
mailbox::{self},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::BlockReason,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{Action, BlockedIp, Http, Jmap},
|
||||
},
|
||||
types::ipmask::IpAddrOrMask,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::{net::Ipv4Addr, sync::Arc, time::Duration};
|
||||
use store::{registry::write::RegistryWrite, write::now};
|
||||
use types::id::Id;
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Security tests...");
|
||||
|
||||
let admin = test.account("[email protected]");
|
||||
|
||||
// Set security settings
|
||||
admin
|
||||
.registry_update_setting(
|
||||
Http {
|
||||
use_x_forwarded: true,
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::UseXForwarded],
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_setting(
|
||||
Jmap {
|
||||
max_concurrent_uploads: Some(4),
|
||||
max_concurrent_requests: Some(8),
|
||||
max_upload_size: 5000000,
|
||||
..Default::default()
|
||||
},
|
||||
&[
|
||||
Property::MaxConcurrentUploads,
|
||||
Property::MaxConcurrentRequests,
|
||||
Property::MaxUploadSize,
|
||||
],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Create a test user
|
||||
let user = test
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
&[],
|
||||
"[email protected]",
|
||||
)
|
||||
.await;
|
||||
let user_id = user.id();
|
||||
|
||||
// Incorrect passwords should be rejected with a 401 error
|
||||
assert!(matches!(
|
||||
Client::new()
|
||||
.credentials(Credentials::basic("[email protected]", "abcde"))
|
||||
.accept_invalid_certs(true) .follow_redirects(["127.0.0.1"])
|
||||
.connect("https://127.0.0.1:8899")
|
||||
.await,
|
||||
Err(jmap_client::Error::Problem(err)) if err.status() == Some(401)));
|
||||
|
||||
// Wait until the beginning of the 5 seconds bucket
|
||||
const LIMIT: u64 = 5;
|
||||
let now = now();
|
||||
let range_start = now / LIMIT;
|
||||
let range_end = (range_start * LIMIT) + LIMIT;
|
||||
tokio::time::sleep(Duration::from_secs(range_end - now)).await;
|
||||
|
||||
// Make sure that the IP address is not blocked before the test
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_query_ids(
|
||||
ObjectType::BlockedIp,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new()
|
||||
)
|
||||
.await,
|
||||
Vec::<Id>::new()
|
||||
);
|
||||
|
||||
for _ in 0..98 {
|
||||
validate_password_with_ip("[email protected]", "wrong password", "127.0.0.1", false)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut imap = ImapConnection::connect(b"_x ").await;
|
||||
imap.send("AUTHENTICATE PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz")
|
||||
.await;
|
||||
imap.assert_read(Type::Tagged, ResponseType::No).await;
|
||||
|
||||
// There are already 100 failed login attempts for this IP address
|
||||
// so the next one should be rejected, even if done over IMAP
|
||||
imap.send("AUTHENTICATE PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz")
|
||||
.await;
|
||||
imap.assert_disconnect().await;
|
||||
|
||||
// Make sure the IP address is blocked
|
||||
let blocked_id = test
|
||||
.server
|
||||
.registry()
|
||||
.primary_key(
|
||||
ObjectType::BlockedIp.into(),
|
||||
Property::Address,
|
||||
IpAddrOrMask::from_ip(Ipv4Addr::LOCALHOST.into()).to_index_key(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("Blocked IP should have been created after too many failed login attempts");
|
||||
let blocked_ip = test
|
||||
.server
|
||||
.registry()
|
||||
.object::<BlockedIp>(blocked_id.id())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(blocked_ip.reason, BlockReason::AuthFailure);
|
||||
|
||||
ImapConnection::connect(b"_y ")
|
||||
.await
|
||||
.assert_disconnect()
|
||||
.await;
|
||||
|
||||
// Lift ban
|
||||
test.server
|
||||
.registry()
|
||||
.write(RegistryWrite::delete(blocked_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_id(trc::location!());
|
||||
test.server
|
||||
.reload_registry(RegistryChange::Delete(blocked_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Valid authentication requests should not be rate limited
|
||||
for _ in 0..110 {
|
||||
validate_password_with_ip(
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
"127.0.0.1",
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Set fail2ban expiration
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Security,
|
||||
Id::singleton(),
|
||||
json!({
|
||||
Property::AuthBanPeriod: registry::types::duration::Duration::from_millis(1000)
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Block IP 10.0.0.2
|
||||
for _ in 0..105 {
|
||||
validate_password_with_ip("[email protected]", "wrong password", "10.0.0.2", false).await;
|
||||
}
|
||||
validate_password_with_ip(
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
"10.0.0.2",
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Check that the IP is blocked
|
||||
let blocked_ids = admin
|
||||
.registry_query_ids(
|
||||
ObjectType::BlockedIp,
|
||||
[(Property::Address, "10.0.0.2")],
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(blocked_ids.len(), 1);
|
||||
let blocked_ip = admin.registry_get::<BlockedIp>(blocked_ids[0]).await;
|
||||
assert_eq!(blocked_ip.reason, BlockReason::AuthFailure);
|
||||
assert!(blocked_ip.expires_at.is_some());
|
||||
|
||||
// After 1 second the ban should be lifted
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
validate_password_with_ip(
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
"10.0.0.2",
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Make sure the IP remains unblocked after reload
|
||||
admin.registry_create_object(Action::ReloadBlockedIps).await;
|
||||
validate_password_with_ip(
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
"10.0.0.2",
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Login with the correct credentials
|
||||
let client = Client::new()
|
||||
.credentials(Credentials::basic(
|
||||
"[email protected]",
|
||||
"this is a very strong password",
|
||||
))
|
||||
.accept_invalid_certs(true)
|
||||
.follow_redirects(["127.0.0.1"])
|
||||
.connect("https://127.0.0.1:8899")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(client.session().username(), "[email protected]");
|
||||
assert_eq!(
|
||||
client
|
||||
.session()
|
||||
.account(&user_id.to_string())
|
||||
.unwrap()
|
||||
.name(),
|
||||
"[email protected]"
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.session()
|
||||
.account(&user_id.to_string())
|
||||
.unwrap()
|
||||
.is_personal()
|
||||
);
|
||||
|
||||
// Uploads up to 5000000 bytes should be allowed
|
||||
assert_eq!(
|
||||
client
|
||||
.upload(None, vec![b'A'; 5000000], None)
|
||||
.await
|
||||
.unwrap()
|
||||
.size(),
|
||||
5000000
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.upload(None, vec![b'A'; 5000001], None)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Concurrent requests check
|
||||
let client = Arc::new(client);
|
||||
let raw_http =
|
||||
HttpRequest::with_credentials(8899, "[email protected]", "this is a very strong password");
|
||||
for _ in 0..8 {
|
||||
let client_ = client.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = client_
|
||||
.mailbox_query(
|
||||
mailbox::query::Filter::name("__sleep").into(),
|
||||
[mailbox::query::Comparator::name()].into(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
"methodCalls": [
|
||||
["Mailbox/query", {
|
||||
"accountId": user_id.to_string(),
|
||||
"filter": { "name": "__sleep" }
|
||||
}, "c1"]
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = raw_http
|
||||
.send_full(
|
||||
hyper::Method::POST,
|
||||
"/jmap/",
|
||||
Some(body),
|
||||
Some("application/json"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status.as_u16(),
|
||||
400,
|
||||
"concurrent-requests body: {}",
|
||||
resp.body
|
||||
);
|
||||
let policy = resp
|
||||
.rate_limit_policy()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers));
|
||||
assert!(
|
||||
policy.contains("\"concurrent-requests\"") && policy.contains("q=8"),
|
||||
"RateLimit-Policy = {policy}"
|
||||
);
|
||||
assert!(
|
||||
policy.contains(r#"qu="concurrent-requests""#),
|
||||
"RateLimit-Policy = {policy}"
|
||||
);
|
||||
let state = resp
|
||||
.rate_limit()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers));
|
||||
assert!(
|
||||
state.contains("\"concurrent-requests\"") && state.contains("r=0"),
|
||||
"RateLimit = {state}"
|
||||
);
|
||||
|
||||
// Wait for sleep to be done
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// Concurrent upload test
|
||||
for _ in 0..4 {
|
||||
let client_ = client.clone();
|
||||
tokio::spawn(async move {
|
||||
client_.upload(None, b"sleep".to_vec(), None).await.unwrap();
|
||||
});
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let resp = raw_http
|
||||
.send_full(
|
||||
hyper::Method::POST,
|
||||
&format!("/jmap/upload/{user_id}"),
|
||||
Some(b"sleep".to_vec()),
|
||||
Some("application/octet-stream"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
resp.status.as_u16(),
|
||||
400,
|
||||
"concurrent-uploads body: {}",
|
||||
resp.body
|
||||
);
|
||||
let policy = resp
|
||||
.rate_limit_policy()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit-Policy header on {:?}", resp.headers));
|
||||
assert!(
|
||||
policy.contains("\"concurrent-uploads\"") && policy.contains("q=4"),
|
||||
"RateLimit-Policy = {policy}"
|
||||
);
|
||||
let state = resp
|
||||
.rate_limit()
|
||||
.unwrap_or_else(|| panic!("missing RateLimit header on {:?}", resp.headers));
|
||||
assert!(
|
||||
state.contains("\"concurrent-uploads\"") && state.contains("r=0"),
|
||||
"RateLimit = {state}"
|
||||
);
|
||||
|
||||
// Wait for sleep to be done before continuing
|
||||
tokio::time::sleep(Duration::from_millis(1000)).await;
|
||||
|
||||
// Disable X-Forwarded-For processing
|
||||
admin
|
||||
.registry_update_setting(
|
||||
Http {
|
||||
use_x_forwarded: false,
|
||||
..Default::default()
|
||||
},
|
||||
&[Property::UseXForwarded],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Destroy account
|
||||
admin.destroy_account(user).await;
|
||||
|
||||
test.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{account::Account, server::TestServer};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::TaskStoreMaintenanceType,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
Task, TaskManager, TaskRetryStrategy, TaskRetryStrategyFixed, TaskStatus,
|
||||
TaskStatusFailed, TaskStatusPending, TaskStatusRetry, TaskStoreMaintenance,
|
||||
},
|
||||
},
|
||||
types::datetime::UTCDateTime,
|
||||
};
|
||||
use serde_json::json;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
const TASK_WAIT_ATTEMPTS: usize = 100;
|
||||
const TASK_WAIT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
const TASK_SUCCESS: u64 = 0;
|
||||
const TASK_TEMP_FAIL: u64 = 1;
|
||||
const TASK_PERM_FAIL: u64 = 2;
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running Task manager tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
|
||||
// Make sure there are no existing tasks
|
||||
admin.assert_no_tasks().await;
|
||||
|
||||
// Create a successful task for immediate execution
|
||||
admin.schedule_test_task(TASK_SUCCESS, 0).await;
|
||||
admin.assert_no_tasks().await;
|
||||
|
||||
// Create a successful task for future execution
|
||||
admin.schedule_test_task(TASK_SUCCESS, 1).await;
|
||||
admin.assert_has_tasks(1).await;
|
||||
admin.assert_no_tasks().await;
|
||||
|
||||
// Create a permanent failure task for immediate execution
|
||||
admin.schedule_test_task(TASK_PERM_FAIL, 0).await;
|
||||
let task = admin.assert_has_failed_task().await;
|
||||
assert_eq!(
|
||||
task.task.status().unwrap_failed().failure_reason,
|
||||
"Simulated permanent failure"
|
||||
);
|
||||
|
||||
// Reschedule the failed task for retry
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Task,
|
||||
task.id,
|
||||
json!({
|
||||
Property::ShardIndex: TASK_SUCCESS,
|
||||
Property::Status: {
|
||||
"@type": "Pending",
|
||||
"due": UTCDateTime::from_timestamp((now() + 1) as i64),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
test.wait_for_tasks().await;
|
||||
admin.assert_no_tasks().await;
|
||||
|
||||
// Test attempt limits strategy
|
||||
admin
|
||||
.registry_update_setting(
|
||||
TaskManager {
|
||||
max_attempts: 3,
|
||||
strategy: TaskRetryStrategy::FixedDelay(TaskRetryStrategyFixed {
|
||||
delay: 1_000u64.into(),
|
||||
}),
|
||||
total_deadline: 86_400_000u64.into(), // 24 hours
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Create a temporary failure task for immediate execution
|
||||
admin.schedule_test_task(TASK_TEMP_FAIL, 0).await;
|
||||
let task = admin.assert_has_retried_task().await;
|
||||
let task_status = task.task.status().unwrap_retry();
|
||||
assert_eq!(task_status.failure_reason, "Simulated temporary failure");
|
||||
assert_eq!(task_status.attempt_number, 1);
|
||||
|
||||
// Wait until the max attempts is reached
|
||||
test.wait_for_tasks_skip_failures().await;
|
||||
let task = admin.assert_has_failed_task().await;
|
||||
let task_status = task.task.status().unwrap_failed();
|
||||
assert_eq!(task_status.failure_reason, "Simulated temporary failure");
|
||||
assert_eq!(task_status.failed_attempt_number, 3);
|
||||
admin
|
||||
.registry_destroy(ObjectType::Task, [task.id])
|
||||
.await
|
||||
.assert_destroyed(&[task.id]);
|
||||
|
||||
// Test attempt limits strategy
|
||||
admin
|
||||
.registry_update_setting(
|
||||
TaskManager {
|
||||
max_attempts: 100,
|
||||
strategy: TaskRetryStrategy::FixedDelay(TaskRetryStrategyFixed {
|
||||
delay: 1_000u64.into(),
|
||||
}),
|
||||
total_deadline: 2_000u64.into(), // 2 seconds
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
|
||||
// Create a temporary failure task for immediate execution
|
||||
admin.schedule_test_task(TASK_TEMP_FAIL, 0).await;
|
||||
let task = admin.assert_has_retried_task().await;
|
||||
let task_status = task.task.status().unwrap_retry();
|
||||
assert_eq!(task_status.failure_reason, "Simulated temporary failure");
|
||||
assert_eq!(task_status.attempt_number, 1);
|
||||
|
||||
// Wait until 2 seconds deadline is reached
|
||||
test.wait_for_tasks_skip_failures().await;
|
||||
let task = admin.assert_has_failed_task().await;
|
||||
let task_status = task.task.status().unwrap_failed();
|
||||
assert_eq!(task_status.failure_reason, "Simulated temporary failure");
|
||||
assert_eq!(task_status.failed_attempt_number, 2);
|
||||
admin
|
||||
.registry_destroy(ObjectType::Task, [task.id])
|
||||
.await
|
||||
.assert_destroyed(&[task.id]);
|
||||
|
||||
pagination_test(test).await;
|
||||
|
||||
test.cleanup().await;
|
||||
}
|
||||
|
||||
async fn pagination_test(test: &mut TestServer) {
|
||||
println!("Running Task pagination tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
|
||||
admin.assert_no_tasks().await;
|
||||
|
||||
let mut created = Vec::with_capacity(12);
|
||||
for i in 0..12u64 {
|
||||
created.push(admin.schedule_test_task(TASK_SUCCESS, 3600 + i).await);
|
||||
}
|
||||
|
||||
let asc_order: Vec<Id> = admin
|
||||
.registry_query_paginated(ObjectType::Task, "due", true, None, None, None, None, false)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
asc_order.len(),
|
||||
12,
|
||||
"expected 12 tasks, got {}",
|
||||
asc_order.len()
|
||||
);
|
||||
|
||||
let desc_order: Vec<Id> = asc_order.iter().rev().copied().collect();
|
||||
|
||||
for chunk_start in [0usize, 5, 10] {
|
||||
let chunk_size = std::cmp::min(5, 12 - chunk_start);
|
||||
|
||||
let asc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::Task,
|
||||
"due",
|
||||
true,
|
||||
Some(chunk_start as i32),
|
||||
Some(5),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
asc,
|
||||
asc_order[chunk_start..chunk_start + chunk_size],
|
||||
"ascending position={chunk_start} limit=5",
|
||||
);
|
||||
|
||||
let desc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::Task,
|
||||
"due",
|
||||
false,
|
||||
Some(chunk_start as i32),
|
||||
Some(5),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
desc,
|
||||
desc_order[chunk_start..chunk_start + chunk_size],
|
||||
"descending position={chunk_start} limit=5",
|
||||
);
|
||||
}
|
||||
|
||||
for anchor_idx in [4usize, 9] {
|
||||
let chunk_size = std::cmp::min(5, 12 - anchor_idx - 1);
|
||||
|
||||
let asc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::Task,
|
||||
"due",
|
||||
true,
|
||||
None,
|
||||
Some(5),
|
||||
Some(asc_order[anchor_idx]),
|
||||
Some(1),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
asc,
|
||||
asc_order[anchor_idx + 1..anchor_idx + 1 + chunk_size],
|
||||
"ascending anchor={} offset=1 limit=5",
|
||||
asc_order[anchor_idx],
|
||||
);
|
||||
|
||||
let desc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::Task,
|
||||
"due",
|
||||
false,
|
||||
None,
|
||||
Some(5),
|
||||
Some(desc_order[anchor_idx]),
|
||||
Some(1),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
desc,
|
||||
desc_order[anchor_idx + 1..anchor_idx + 1 + chunk_size],
|
||||
"descending anchor={} offset=1 limit=5",
|
||||
desc_order[anchor_idx],
|
||||
);
|
||||
}
|
||||
|
||||
let response = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::Task,
|
||||
"due",
|
||||
true,
|
||||
Some(0),
|
||||
Some(5),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let total = response
|
||||
.pointer("/methodResponses/0/1/total")
|
||||
.and_then(|v| v.as_u64());
|
||||
assert_eq!(total, Some(12), "expected calculateTotal=12");
|
||||
|
||||
admin
|
||||
.registry_destroy(ObjectType::Task, created.clone())
|
||||
.await
|
||||
.assert_destroyed(&created);
|
||||
admin.assert_no_tasks().await;
|
||||
}
|
||||
|
||||
impl Account {
|
||||
async fn schedule_test_task(&self, test_type: u64, schedule_in: u64) -> Id {
|
||||
self.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
|
||||
maintenance_type: TaskStoreMaintenanceType::RemoveLockDav,
|
||||
shard_index: Some(test_type),
|
||||
status: TaskStatus::at((now() + schedule_in) as i64),
|
||||
}))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn task_ids(&self) -> Vec<Id> {
|
||||
self.registry_query_ids(
|
||||
ObjectType::Task,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn tasks(&self) -> Vec<TaskId> {
|
||||
let ids = self.task_ids().await;
|
||||
let mut results = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let sample = self.registry_get::<Task>(id).await;
|
||||
results.push(TaskId { id, task: sample });
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
async fn assert_no_tasks(&self) {
|
||||
self.await_tasks(0, |_| true).await;
|
||||
}
|
||||
|
||||
async fn assert_has_tasks(&self, count: usize) -> Vec<TaskId> {
|
||||
self.await_tasks(count, |_| true).await
|
||||
}
|
||||
|
||||
async fn assert_has_failed_task(&self) -> TaskId {
|
||||
self.await_tasks(1, |task| {
|
||||
matches!(task.task.status(), TaskStatus::Failed(_))
|
||||
})
|
||||
.await
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn assert_has_retried_task(&self) -> TaskId {
|
||||
self.await_tasks(1, |task| matches!(task.task.status(), TaskStatus::Retry(_)))
|
||||
.await
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn await_tasks(
|
||||
&self,
|
||||
count: usize,
|
||||
is_expected: impl Fn(&TaskId) -> bool,
|
||||
) -> Vec<TaskId> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let tasks = self.tasks().await;
|
||||
if tasks.len() == count && tasks.iter().all(&is_expected) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
assert!(
|
||||
attempt < TASK_WAIT_ATTEMPTS,
|
||||
"Expected {} tasks, found {}: {:?}",
|
||||
count,
|
||||
tasks.len(),
|
||||
tasks
|
||||
);
|
||||
tokio::time::sleep(TASK_WAIT_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TaskId {
|
||||
pub id: Id,
|
||||
pub task: Task,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
trait UnwrapTaskStatus {
|
||||
fn unwrap_pending(&self) -> &TaskStatusPending;
|
||||
fn unwrap_retry(&self) -> &TaskStatusRetry;
|
||||
fn unwrap_failed(&self) -> &TaskStatusFailed;
|
||||
}
|
||||
|
||||
impl UnwrapTaskStatus for TaskStatus {
|
||||
fn unwrap_pending(&self) -> &TaskStatusPending {
|
||||
match self {
|
||||
TaskStatus::Pending(status) => status,
|
||||
_ => panic!("Expected TaskStatus::Pending, found {:?}", self),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_retry(&self) -> &TaskStatusRetry {
|
||||
match self {
|
||||
TaskStatus::Retry(status) => status,
|
||||
_ => panic!("Expected TaskStatus::Retry, found {:?}", self),
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_failed(&self) -> &TaskStatusFailed {
|
||||
match self {
|
||||
TaskStatus::Failed(status) => status,
|
||||
_ => panic!("Expected TaskStatus::Failed, found {:?}", self),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user