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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+816
View File
@@ -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"
),
];
+431
View File
@@ -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
);
}
}
+391
View File
@@ -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-----
";
+289
View File
@@ -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(&params.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}"
);
}
}
+705
View File
@@ -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()
}
+680
View File
@@ -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;
}
+76
View File
@@ -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
+255
View File
@@ -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)
}
+511
View File
@@ -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()
}
+391
View File
@@ -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;
}
+395
View File
@@ -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),
}
}
}