Files
inbuxa-server/tests/src/system/account_lock.rs
T
jcoffey-dev 447229f871
ci / fork-checks (pull_request) Successful in 1m4s
ci / build (pull_request) Successful in 8m47s
Lock accounts: keep receiving mail, no sign-in, hand to delegates
A locked account can't sign in (it fails as a wrong password does), its
sessions end on every node, refresh tokens stop working, and its Sieve
scripts forward and reply to nothing. Mail keeps arriving.

Delegates get real ACL grants on the account's mailboxes, calendars,
address books and files at read, organize or full, with the rights they
replaced restored on unlock. Folders made later are granted after the
create and in a daily sweep. Organize delegates can't destroy; send-as
needs organize or full. The JMAP session marks delegated accounts in
urn:inbuxa:jmap.

New inbuxa:AccountLock object with get/set, permissions 665-668, and a
Compliance > Locked Accounts entry in the schema. Lock, unlock and
delegate changes need a reason and are audited; delegate access and
writes are audited too (audit-hold-lock spec AL-1 to AL-12).
2026-09-27 14:46:06 -07:00

437 lines
15 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: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");
// 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}"
);
// 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-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();
}
}