Files
inbuxa-server/tests/src/system/account_lock.rs
T
jcoffey-dev 9f6761c9dd
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 17m56s
Writing delegates may add at the top of a locked account's Files
A shared account refuses top-level folders, so an organize or full
delegate couldn't add anything to a locked account with no folders. A
delegate who may write now can, as the owner could; the reconcile after
the create grants it the new folder. Read delegates still can't (AL-6,
AL-7).
2026-09-27 18:04:24 -07:00

514 lines
18 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Account lock with delegation acceptance tests, from
//! `inbuxa-drafts/specs/audit-hold-lock.md` (AL-1 to AL-12; tests 10 to 15
//! of its list). Each check names the requirement or test number.
use crate::{
jmap::mail::submission::{
MockMessage, assert_message_delivery, expect_nothing, spawn_mock_smtp_server,
},
utils::{
account::Account,
dns::DnsCache,
server::{TestServer, TestServerBuilder},
smtp::SmtpConnection,
},
};
use registry::{
schema::{
enums::MtaProtocol,
structs::{
Expression, ExpressionMatch, Imap, MtaOutboundStrategy, MtaRoute, MtaRouteRelay,
MtaStageAuth,
},
},
types::list::List,
};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
"urn:ietf:params:jmap:calendars",
"urn:ietf:params:jmap:contacts",
"urn:ietf:params:jmap:filenode",
"urn:inbuxa:jmap",
];
const OWNER_SECRET: &str = "owner-secret-4471";
const DELEGATE_SECRET: &str = "delegate-secret-9902";
impl Account {
async fn call(&self, method: &str, arguments: Value) -> (String, Value) {
let response = self.jmap_request(USING, json!([[method, arguments, "0"]])).await;
let call = response
.0
.pointer("/methodResponses/0")
.cloned()
.unwrap_or_else(|| panic!("{method}: {}", response.0));
(call[0].as_str().unwrap_or_default().to_string(), call[1].clone())
}
async fn lock_set(&self, arguments: Value) -> Value {
let mut arguments = arguments;
arguments["accountId"] = self.id_string().into();
let (name, response) = self.call("inbuxa:AccountLock/set", arguments).await;
assert_eq!(name, "inbuxa:AccountLock/set", "{response}");
response
}
async fn session_status(&self) -> u16 {
self.http_get_raw(&format!("{}/jmap/session", self.base_url()), None)
.await
.status
}
}
fn message(from: &str, subject: &str) -> String {
format!("From: {from}\r\nTo: [email protected]\r\nSubject: {subject}\r\n\r\nHello.\r\n")
}
pub async fn test(test: &mut TestServer) {
println!("Running account lock tests...");
let admin = test.account("[email protected]");
let owner = admin
.create_user_account("[email protected]", OWNER_SECRET, "Owner", &[], vec![])
.await;
let delegate = admin
.create_user_account("[email protected]", DELEGATE_SECRET, "Delegate", &[], vec![])
.await;
let owner_id = owner.id_string().to_string();
// Mail leaving the server goes to the mock
let (mut smtp_rx, _smtp_settings) = spawn_mock_smtp_server();
test.server.ipv4_add(
"localhost",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(60),
);
// The owner set a vacation reply before leaving
owner
.jmap_client()
.await
.vacation_response_enable("Away", "I'm away.".into(), None::<String>)
.await
.unwrap();
// Control: before the lock, the vacation reply goes out, so its absence
// later means the lock stopped it
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
&message("[email protected]", "Before the lock"),
)
.await;
assert_message_delivery(
&mut smtp_rx,
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Away"),
)
.await;
let right_password = owner.session_status().await;
assert_eq!(right_password, 200, "the owner can sign in before the lock");
let wrong = Account::new("[email protected]", "wrong-password", &[], "", owner.id());
let wrong_password = wrong.session_status().await;
// AU-12: no lock without a reason
let response = admin
.lock_set(json!({"create": {"l": {"accountId": owner_id,
"delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}}))
.await;
assert_eq!(
response["notCreated"]["l"]["type"], "invalidProperties",
"AU-12: {response}"
);
// AL-12: nobody locks themselves
let response = admin
.lock_set(json!({"create": {"l": {"accountId": admin.id_string(), "reason": "test"}}}))
.await;
assert_eq!(response["notCreated"]["l"]["type"], "forbidden", "AL-12: {response}");
// AL-8: send-as needs more than read
let response = admin
.lock_set(json!({"create": {"l": {"accountId": owner_id, "reason": "Left",
"delegates": [{"accountId": delegate.id_string(), "access": "read", "sendAs": true}]}}}))
.await;
assert_eq!(
response["notCreated"]["l"]["type"], "invalidProperties",
"AL-8: {response}"
);
// Lock, with a read-only delegate (AL-1)
let response = admin
.lock_set(json!({"create": {"l": {"accountId": owner_id,
"reason": "Left the company; mail to be reviewed",
"delegates": [{"accountId": delegate.id_string(), "access": "read"}]}}}))
.await;
assert_eq!(response["created"]["l"]["id"], owner_id.as_str(), "AL-1: {response}");
// AL-2: the right password fails as a wrong one does
let right_password = owner.session_status().await;
assert_ne!(right_password, 200, "AL-2: a locked account signed in");
assert_eq!(
right_password, wrong_password,
"AL-2: the right password is told apart from a wrong one"
);
// Test 11, AL-4: mail keeps arriving, and nothing is sent: no vacation
lmtp.ingest(
"[email protected]",
&["[email protected]"],
&message("[email protected]", "Quarterly report"),
)
.await;
expect_nothing(&mut smtp_rx).await;
// AL-7: the delegate sees the account, read-only, marked as delegated
let session = delegate.jmap_session_object().await.0;
let entry = &session["accounts"][owner_id.as_str()];
assert_eq!(entry["isPersonal"], false, "AL-7: {session}");
assert_eq!(entry["isReadOnly"], true, "AL-6: {entry}");
let delegation = &entry["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"];
assert_eq!(delegation["locked"], true, "AL-7: {entry}");
assert_eq!(delegation["access"], "read", "AL-7");
assert_eq!(delegation["sendAs"], false, "AL-7");
// AL-7: the whole account, not only mail: even a kind the owner holds
// none of (no files here) reads as empty rather than refused
for (method, arguments) in [
("FileNode/query", json!({"accountId": owner_id})),
("Calendar/get", json!({"accountId": owner_id, "ids": null})),
("AddressBook/get", json!({"accountId": owner_id, "ids": null})),
] {
let (name, response) = delegate.call(method, arguments).await;
assert_eq!(name, method, "AL-7: {method} refused to the delegate: {response}");
}
// AL-6: reading adds nothing, not even at the top of empty Files
let (_, response) = delegate
.call(
"FileNode/set",
json!({"accountId": owner_id, "create": {"f": {"name": "Notes", "parentId": null}}}),
)
.await;
assert!(response["created"].get("f").is_none(), "AL-6: a read delegate added a file: {response}");
// The delegate reads the mail that arrived
let (_, found) = delegate
.call(
"Email/query",
json!({"accountId": owner_id, "filter": {"text": "Quarterly"}}),
)
.await;
let email_id = found["ids"][0]
.as_str()
.unwrap_or_else(|| panic!("AL-4: the mail didn't arrive: {found}"))
.to_string();
// Test 12, AL-6: read means nothing changes, not even $seen
let (_, response) = delegate
.call(
"Email/set",
json!({"accountId": owner_id, "update": {email_id.as_str(): {"keywords/$seen": true}}}),
)
.await;
assert!(
response["notUpdated"][email_id.as_str()].is_object(),
"test 12: a read delegate changed a keyword: {response}"
);
// AU-12: changing delegates needs a reason
let response = admin
.lock_set(json!({"update": {owner_id.as_str(): {"delegates": [
{"accountId": delegate.id_string(), "access": "organize"}]}}}))
.await;
assert_eq!(
response["notUpdated"][owner_id.as_str()]["type"], "invalidProperties",
"AU-12: {response}"
);
let response = admin
.lock_set(json!({"reason": "Manager files the mail",
"update": {owner_id.as_str(): {"delegates": [
{"accountId": delegate.id_string(), "access": "organize"}]}}}))
.await;
assert!(
response["updated"].get(owner_id.as_str()).is_some(),
"AL-5: {response}"
);
// AL-7: organize adds at the top of the locked account's Files, which
// held none, and sees what it made
let (_, response) = delegate
.call(
"FileNode/set",
json!({"accountId": owner_id, "create": {"f": {"name": "Handover notes", "parentId": null}}}),
)
.await;
let folder_id = response["created"]["f"]["id"]
.as_str()
.unwrap_or_else(|| panic!("AL-7: organize couldn't add to empty Files: {response}"))
.to_string();
let (_, response) = delegate
.call("FileNode/get", json!({"accountId": owner_id, "ids": [folder_id]}))
.await;
assert_eq!(
response["list"].as_array().map(Vec::len),
Some(1),
"AL-7: the delegate can't see the folder it made: {response}"
);
// Test 12, AL-6, AL-7: organize makes folders it can see, moves mail,
// never deletes it
let (_, mailboxes) = delegate
.call("Mailbox/get", json!({"accountId": owner_id, "ids": null}))
.await;
let inbox = mailboxes["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "inbox")
.unwrap_or_else(|| panic!("AL-7: no inbox seen: {mailboxes}"))["id"]
.as_str()
.unwrap()
.to_string();
let (_, created) = delegate
.call(
"Mailbox/set",
json!({"accountId": owner_id, "create": {"f": {"name": "Reviewed", "parentId": inbox}}}),
)
.await;
let folder = created["created"]["f"]["id"]
.as_str()
.unwrap_or_else(|| panic!("AL-6: organize couldn't make a folder: {created}"))
.to_string();
let (_, mailboxes) = delegate
.call("Mailbox/get", json!({"accountId": owner_id, "ids": [folder]}))
.await;
assert_eq!(
mailboxes["list"].as_array().map(Vec::len),
Some(1),
"AL-7: the delegate can't see the folder it made: {mailboxes}"
);
let (_, moved) = delegate
.call(
"Email/set",
json!({"accountId": owner_id, "update": {email_id.as_str(): {
"mailboxIds": {folder.as_str(): true}}}}),
)
.await;
assert!(
moved["updated"].get(email_id.as_str()).is_some(),
"test 12: organize couldn't move mail: {moved}"
);
let (_, destroyed) = delegate
.call(
"Email/set",
json!({"accountId": owner_id, "destroy": [email_id]}),
)
.await;
assert_eq!(
destroyed["notDestroyed"][email_id.as_str()]["type"], "forbidden",
"test 12: organize deleted mail: {destroyed}"
);
// AL-8: no sending without send-as
let (name, _) = delegate
.call("Identity/get", json!({"accountId": owner_id, "ids": null}))
.await;
assert_eq!(name, "error", "AL-8: identities of a locked account without send-as");
// Test 11, AL-4: a Sieve reject is kept instead, and nobody is answered
admin
.jmap_client()
.await
.set_default_account_id(owner_id.clone())
.sieve_script_create(
"rejector",
"require \"reject\";\r\nreject \"Not here.\";\r\n",
true,
)
.await
.unwrap();
lmtp.ingest(
"[email protected]",
&["[email protected]"],
&message("[email protected]", "Invoice 77"),
)
.await;
expect_nothing(&mut smtp_rx).await;
let (_, kept) = delegate
.call(
"Email/query",
json!({"accountId": owner_id, "filter": {"text": "Invoice"}}),
)
.await;
assert_eq!(
kept["ids"].as_array().map(Vec::len),
Some(1),
"AL-4: the rejected message wasn't kept: {kept}"
);
// AL-5: a delegation ends at its `until`, not at the next daily sweep
let soon = store::write::now() + 3;
let response = admin
.lock_set(json!({"reason": "Handover ends shortly",
"update": {owner_id.as_str(): {"delegates": [
{"accountId": delegate.id_string(), "access": "organize",
"until": chrono::DateTime::from_timestamp(soon as i64, 0).unwrap().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)}]}}}))
.await;
assert!(
response["updated"].get(owner_id.as_str()).is_some(),
"AL-5: {response}"
);
let (_, before) = delegate
.call("Mailbox/get", json!({"accountId": owner_id, "ids": null}))
.await;
assert!(
before["list"].as_array().is_some_and(|l| !l.is_empty()),
"AL-5: the delegate lost the account before its end: {before}"
);
tokio::time::sleep(Duration::from_secs(6)).await;
let session = delegate.jmap_session_object().await.0;
assert!(
session["accounts"].get(owner_id.as_str()).is_none(),
"AL-5: the delegation outlived its end in the session: {session}"
);
let (_, after) = delegate
.call("Mailbox/get", json!({"accountId": owner_id, "ids": null}))
.await;
assert!(
after["list"].as_array().is_none_or(|l| l.is_empty()),
"AL-5: the delegate still reaches the folders after its end: {after}"
);
// AL-10: unlocking needs a reason, then restores everything
let response = admin
.lock_set(json!({"destroy": [owner_id]}))
.await;
assert_eq!(
response["notDestroyed"][owner_id.as_str()]["type"], "invalidProperties",
"AU-12: {response}"
);
let response = admin
.lock_set(json!({"reason": "Review done", "destroy": [owner_id]}))
.await;
assert_eq!(response["destroyed"][0], owner_id.as_str(), "AL-10: {response}");
assert_eq!(owner.session_status().await, 200, "AL-10: the owner can sign in");
let session = delegate.jmap_session_object().await.0;
assert!(
session["accounts"].get(owner_id.as_str()).is_none(),
"test 15: the delegate kept the account: {session}"
);
// AL-9, AU-12: every step is recorded, with its reason
let (_, query) = admin
.call(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(), "filter": {"targetKind": "inbuxa:AccountLock"}}),
)
.await;
let (_, records) = admin
.call(
"inbuxa:AuditEvent/get",
json!({"accountId": admin.id_string(), "ids": query["ids"]}),
)
.await;
let reasons = records["list"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["outcome"]["status"] == "success")
.filter_map(|r| r["reason"].as_str())
.collect::<Vec<_>>();
for reason in [
"Left the company; mail to be reviewed",
"Manager files the mail",
"Review done",
] {
assert!(reasons.contains(&reason), "AU-12: {reason} missing from {reasons:?}");
}
let (_, query) = admin
.call(
"inbuxa:AuditEvent/query",
json!({"accountId": admin.id_string(),
"filter": {"actorId": delegate.id_string(), "accountId": owner_id}}),
)
.await;
assert!(
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
"AL-9: the delegate's access and changes weren't recorded: {query}"
);
}
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn account_lock_tests() {
let mut test = TestServerBuilder::new("account_lock_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
admin
.registry_create_object(Imap {
allow_plain_text_auth: true,
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageAuth {
require: Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaOutboundStrategy {
route: Expression {
match_: List::from_iter([
ExpressionMatch {
if_: "rcpt_domain == 'example.com'".into(),
then: "'local'".into(),
},
ExpressionMatch {
if_: "rcpt_domain == 'remote.org'".into(),
then: "'mock-smtp'".into(),
},
]),
else_: "'mx'".to_string(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
address: "127.0.0.1".into(),
port: 9999,
allow_invalid_certs: true,
implicit_tls: false,
name: "mock-smtp".into(),
protocol: MtaProtocol::Smtp,
..Default::default()
}))
.await;
admin.reload_settings().await;
test.insert_account(admin);
self::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}