Files
inbuxa-server/tests/src/system/journal.rs
T
jcoffey-dev daa486f7e7
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 8m0s
Journaling: search, read and export over JMAP, and the chain check
Phase 4 of the journaling spec.

- inbuxa:JournalEntry/query and /get (sysJournalSearch): filter by time,
  sender, recipient, either, direction, subject words, Message-ID and
  journal, newest first; the whole report only when asked for.
- inbuxa:JournalExport/set (sysJournalExport): a reason is required; a
  ZIP of the matching reports with manifest.csv, exceptions.csv and
  manifest.sha256, up to 10,000 reports and 1 GB.
- inbuxa:JournalVerification/set (sysJournalGet): chains and reports
  rechecked.
- Every search, listing, read, export and check is written to the audit
  log before anything is returned, with existing actions only.
- Catalog entries for the three objects; spec as-built notes.

journal_tests: administrators can't search; a Compliance Officer searches,
lists, reads a report, exports (reason required) and checks the chain;
the officer can't change journals; each of those is in the audit log.
2026-09-28 21:45:09 -07:00

932 lines
31 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Journaling (journaling spec, phase 2): journals over JMAP, the copy
//! taken as mail is queued with its whole envelope, the report around the
//! untouched message, retention, purge, and a chain that shows tampering.
use crate::utils::{
account::Account,
server::{TestServer, TestServerBuilder},
smtp::SmtpConnection,
};
use inbuxa_features::journal::{
Direction,
entries::{self, Entry, EntryId},
report,
};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{CustomRoles, Expression, MtaStageAuth, Role, UserRoles},
};
use registry::types::map::Map;
use serde_json::{Value, json};
use std::str::FromStr;
use store::{Deserialize, write::BatchBuilder};
const USING: &[&str] = &[
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
"urn:inbuxa:jmap",
"urn:inbuxa:jmap:registry",
];
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
if arguments.get("accountId").is_none() {
arguments["accountId"] = account.id_string().into();
}
let response = account
.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(),
)
}
/// Sends a message whose headers name `to`, to the envelope `rcpt_to`.
async fn send(
sender: &Account,
identity: &str,
mailbox: &str,
to: &[&str],
rcpt_to: &[&str],
subject: &str,
) -> Value {
let (_, response) = call(
sender,
"Email/set",
json!({"create": {"e": {
"mailboxIds": {mailbox: true},
"from": [{"email": sender.name()}],
"to": to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>(),
"subject": subject,
"bodyValues": {"b": {"value": "The body."}},
"textBody": [{"partId": "b", "type": "text/plain"}]
}}}),
)
.await;
let email = response["created"]["e"]["id"]
.as_str()
.unwrap_or_else(|| panic!("draft: {response}"))
.to_string();
call(
sender,
"EmailSubmission/set",
json!({"create": {"s": {
"emailId": email,
"identityId": identity,
"envelope": {
"mailFrom": {"email": sender.name()},
"rcptTo": rcpt_to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>()
}
}}}),
)
.await
.1
}
async fn all_entries(test: &TestServer) -> Vec<(EntryId, Entry)> {
entries::list(test.server.store(), 0, u64::MAX, 10_000)
.await
.unwrap()
}
async fn entry_for(test: &TestServer, subject: &str) -> Option<(EntryId, Entry)> {
all_entries(test)
.await
.into_iter()
.find(|(_, e)| e.subject == subject)
}
async fn report_of(test: &TestServer, entry: &Entry) -> Vec<u8> {
let hash = entry.blob_hash().expect("blob hash");
test.server
.blob_store()
.get_blob(hash.as_slice(), 0..usize::MAX)
.await
.unwrap()
.expect("report blob")
}
pub async fn test(test: &mut TestServer) {
println!("Running journaling tests...");
let admin = test.account("[email protected]");
let sender = admin
.create_user_account(
"[email protected]",
"journal-sender-secret-7101",
"Journal sender",
&[],
vec![],
)
.await;
let other = admin
.create_user_account(
"[email protected]",
"journal-other-secret-7102",
"Journal other",
&[],
vec![],
)
.await;
let (_, response) = call(
&sender,
"Identity/set",
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
)
.await;
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&sender,
"Mailbox/set",
json!({"create": {"m": {"name": "Journal drafts"}}}),
)
.await;
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
// Nothing is journaled while there are no journals
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"Before any journal",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
assert!(all_entries(test).await.is_empty());
// Journals: checked when written, the server's own properties refused
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"create": {
"short": {"name": "Short", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 29},
"both": {"name": "Both", "enabled": true, "direction": "any",
"scope": {"everyone": true, "accounts": [sender.id_string()]},
"retentionDays": 365},
"none": {"name": "Nowhere", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365, "builtIn": false},
"badaddr": {"name": "Bad archive", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365,
"archiveAddress": "not an address"},
"server": {"name": "Mine", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365,
"createdBy": "me"},
"all": {"name": "Everything", "enabled": true, "direction": "any",
"scope": {"everyone": true}, "retentionDays": 365},
"out": {"name": "Sender's outgoing", "enabled": true, "direction": "outgoing",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 3650}
}}),
)
.await;
for refused in ["short", "both", "none", "badaddr", "server"] {
assert_eq!(
response["notCreated"][refused]["type"], "invalidProperties",
"{refused}: {response}"
);
}
assert_eq!(
response["notCreated"]["short"]["properties"],
json!(["retentionDays"])
);
assert_eq!(
response["notCreated"]["both"]["properties"],
json!(["scope"])
);
assert_eq!(
response["notCreated"]["none"]["properties"],
json!(["builtIn"])
);
assert_eq!(
response["notCreated"]["badaddr"]["properties"],
json!(["archiveAddress"])
);
let everything = response["created"]["all"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
let outgoing = response["created"]["out"]["id"]
.as_str()
.unwrap()
.to_string();
let (_, response) = call(&admin, "inbuxa:Journal/get", json!({"ids": null})).await;
let list = response["list"].as_array().unwrap();
assert_eq!(list.len(), 2, "{response}");
assert_eq!(list[0]["name"], "Everything");
assert_eq!(list[0]["createdBy"], "[email protected]");
assert_eq!(list[1]["scope"]["accounts"], json!([sender.id_string()]));
// Each node reads journals again within 30 seconds; this one at once
inbuxa_features::journal::invalidate();
// Internal mail with a Bcc recipient: one entry, the whole envelope
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]", "[email protected]"],
"Internal with Bcc",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let (_, entry) = entry_for(test, "Internal with Bcc")
.await
.expect("journaled");
assert_eq!(entry.direction, Direction::Internal);
assert_eq!(entry.sender, "[email protected]");
assert!(entry.authenticated);
assert_eq!(entry.recipients.len(), 2, "{entry:?}");
assert_eq!(
entry.journals.len(),
1,
"internal isn't outgoing: {entry:?}"
);
assert!(!entry.held);
assert_eq!(entry.expires_at, entry.at + 365 * 86_400);
let bytes = report_of(test, &entry).await;
assert_eq!(entries::sha256(&bytes), entry.sha256);
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("Direction: internal\r\n"), "{text}");
assert!(
text.contains("To: [email protected]\r\n"),
"{text}"
);
assert!(
text.contains("Bcc: [email protected]\r\n"),
"{text}"
);
let original = report::original(&bytes).expect("original part");
let original = String::from_utf8_lossy(original);
assert!(
original.contains("Subject: Internal with Bcc"),
"{original}"
);
assert!(original.contains("The body."), "{original}");
assert!(!original.contains("Bcc:"), "the original is as sent");
// Outgoing: both journals take it, and it's kept for the longer
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"Leaving",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let (leaving_id, entry) = entry_for(test, "Leaving").await.expect("journaled");
assert_eq!(entry.direction, Direction::Outgoing);
assert_eq!(entry.journals.len(), 2, "{entry:?}");
assert_eq!(entry.expires_at, entry.at + 3650 * 86_400);
// Incoming from outside
admin
.registry_create_object(MtaStageAuth {
require: Expression {
else_: "false".to_string(),
..Default::default()
},
..Default::default()
})
.await;
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
"From: [email protected]\r\nTo: [email protected]\r\nSubject: Arriving\r\n\r\nHi.\r\n",
)
.await;
let (_, entry) = entry_for(test, "Arriving").await.expect("journaled");
assert_eq!(entry.direction, Direction::Incoming);
assert!(!entry.authenticated);
assert_eq!(entry.accounts, vec![other.id().document_id()]);
// The chain checks out, reports included
let store = test.server.store();
let blobs = test.server.blob_store();
let reports = entries::verify(store, Some(blobs)).await.unwrap();
assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}");
let journaled = all_entries(test).await.len() as u64;
assert!(reports.iter().map(|r| r.entries).sum::<u64>() >= journaled);
// An entry changed in the store shows; put back, it checks out again
let key = entries::content_key(leaving_id);
let stored = store
.get_value::<Raw>(key.clone())
.await
.unwrap()
.expect("stored entry")
.0;
let mut forged: Entry = serde_json::from_slice(&stored).unwrap();
forged.recipients = vec!["[email protected]".into()];
let mut batch = BatchBuilder::new();
batch.set(key.class.clone(), serde_json::to_vec(&forged).unwrap());
store.write(batch.build_all()).await.unwrap();
let reports = entries::verify(store, None).await.unwrap();
let broken = reports
.iter()
.find(|r| r.broken_at.is_some())
.expect("broken");
assert_eq!(
broken.broken_at.as_deref(),
Some(leaving_id.to_string().as_str())
);
assert!(
broken
.reason
.as_deref()
.unwrap_or_default()
.contains("changed")
);
let mut batch = BatchBuilder::new();
batch.set(key.class.clone(), stored.clone());
store.write(batch.build_all()).await.unwrap();
assert!(
entries::verify(store, None)
.await
.unwrap()
.iter()
.all(|r| r.broken_at.is_none())
);
// An entry removed without a purge shows too
let mut batch = BatchBuilder::new();
batch.clear(key.class.clone());
store.write(batch.build_all()).await.unwrap();
let reports = entries::verify(store, None).await.unwrap();
assert!(
reports.iter().any(|r| r
.reason
.as_deref()
.unwrap_or_default()
.contains("before its time")),
"{reports:?}"
);
let mut batch = BatchBuilder::new();
batch.set(key.class.clone(), stored);
store.write(batch.build_all()).await.unwrap();
// Retention: nothing is due yet; a year on, what's kept for a hold
// stays, the rest goes, and the chain still checks out
let now = store::write::now();
let purged = entries::purge(store, now, |_| false).await.unwrap();
assert_eq!(purged.removed, 0);
let sender_id = sender.id().document_id();
let later = now + 400 * 86_400;
let purged = entries::purge(store, later, |e| e.accounts.contains(&sender_id))
.await
.unwrap();
assert!(purged.removed >= 1, "{purged:?}");
assert!(purged.kept_for_hold >= 1, "{purged:?}");
assert!(entry_for(test, "Arriving").await.is_none(), "purged");
assert!(entry_for(test, "Internal with Bcc").await.is_some(), "held");
assert!(entry_for(test, "Leaving").await.is_some(), "ten years");
let reports = entries::verify(store, Some(blobs)).await.unwrap();
assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}");
assert!(reports.iter().map(|r| r.purged).sum::<u64>() >= 1);
// Once the hold is gone the held entry goes too
let purged = entries::purge(store, later, |_| false).await.unwrap();
assert!(purged.removed >= 1, "{purged:?}");
assert!(entry_for(test, "Internal with Bcc").await.is_none());
assert!(
entries::verify(store, Some(blobs))
.await
.unwrap()
.iter()
.all(|r| r.broken_at.is_none())
);
// Changing a journal's retention doesn't touch what it has taken
let before = entry_for(test, "Leaving").await.unwrap().1.expires_at;
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"update": {outgoing.clone(): {"retentionDays": 30}}}),
)
.await;
assert!(response["updated"].get(&outgoing).is_some(), "{response}");
assert_eq!(
entry_for(test, "Leaving").await.unwrap().1.expires_at,
before
);
// Journals turned off or removed take nothing more; entries stay
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"update": {everything.clone(): {"enabled": false}}, "destroy": [outgoing]}),
)
.await;
assert!(response["updated"].get(&everything).is_some(), "{response}");
assert_eq!(response["destroyed"].as_array().map(|d| d.len()), Some(1));
inbuxa_features::journal::invalidate();
let count = all_entries(test).await.len();
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"After the journals",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
assert_eq!(all_entries(test).await.len(), count);
assert!(entry_for(test, "Leaving").await.is_some());
// Every change to a journal is in the audit log
let (_, response) = call(
&admin,
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:Journal"}}),
)
.await;
assert!(
response["ids"].as_array().map_or(0, |ids| ids.len()) >= 4,
"{response}"
);
}
/// Phase 3: journals only rules send mail to, recipients a rule added,
/// reports sent to an outside archive, and what happens when the archive
/// doesn't take one.
pub async fn archive(test: &mut TestServer) {
println!("Running journal archive tests...");
let admin = test.account("[email protected]");
let sender = admin
.create_user_account(
"[email protected]",
"archive-sender-secret-7201",
"Archive sender",
&[],
vec![],
)
.await;
let vault = admin
.create_user_account(
"[email protected]",
"journal-vault-secret-7202",
"Journal vault",
&[],
vec![],
)
.await;
let (_, response) = call(
&sender,
"Identity/set",
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
)
.await;
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&sender,
"Mailbox/set",
json!({"create": {"m": {"name": "Archive drafts"}}}),
)
.await;
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"create": {
"rules": {"name": "Only what rules send", "enabled": true, "direction": "any",
"scope": {}, "retentionDays": 30},
"local": {"name": "To the vault", "enabled": true, "direction": "outgoing",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30,
"builtIn": false, "archiveAddress": "[email protected]"},
"remote": {"name": "To an outside archive", "enabled": true, "direction": "internal",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30,
"builtIn": false, "archiveAddress": "[email protected]"}
}}),
)
.await;
let id = |name: &str| {
response["created"][name]["id"]
.as_str()
.unwrap_or_else(|| panic!("{name}: {response}"))
.to_string()
};
let (rules_only, local, remote) = (id("rules"), id("local"), id("remote"));
let number = |id: &str| types::id::Id::from_str(id).unwrap().document_id();
let (_, response) = call(
&admin,
"inbuxa:MailRule/set",
json!({"create": {"r": {
"name": "Copy and journal", "kind": "transport", "direction": "outgoing",
"conditions": [{"type": "words", "words": ["journal-me"]}],
"actions": [
{"type": "addRecipient", "address": "[email protected]"},
{"type": "journal", "journal": rules_only.clone()}
]
}}}),
)
.await;
let rule = response["created"]["r"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
inbuxa_features::journal::invalidate();
// A rule sends it to a journal whose scope takes nobody, and says who
// it added
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"Marked journal-me",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let entry = all_entries(test)
.await
.into_iter()
.map(|(_, e)| e)
.find(|e| e.subject == "Marked journal-me" && e.journals.contains(&number(&rules_only)))
.expect("journaled by the rule");
assert_eq!(entry.journals, vec![number(&rules_only)], "{entry:?}");
let text = String::from_utf8_lossy(&report_of(test, &entry).await).into_owned();
assert!(
text.contains("Added by rule: Copy and journal -> [email protected]\r\n"),
"{text}"
);
assert!(!text.contains("Bcc:"), "{text}");
// The same message went to the outside archive, which can't be reached
// from here: once it leaves the queue (given up on, or deleted), it's
// kept in the built-in journal
let fallback = |entries: &[(EntryId, Entry)]| {
entries
.iter()
.any(|(_, e)| e.subject == "Marked journal-me" && e.journals == vec![number(&remote)])
};
let mut deleted = false;
for _ in 0..100 {
if fallback(&all_entries(test).await) {
break;
}
let (_, response) = call(&admin, "x:QueuedMessage/get", json!({"ids": null})).await;
if let Some(queued) = response["list"]
.as_array()
.unwrap()
.iter()
.find(|m| m.to_string().contains("[email protected]"))
{
let queued_id = queued["id"].as_str().unwrap().to_string();
let (_, response) = call(
&admin,
"x:QueuedMessage/set",
json!({"destroy": [queued_id.clone()]}),
)
.await;
deleted = response["destroyed"] == json!([queued_id]);
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let kept: Vec<Entry> = all_entries(test)
.await
.into_iter()
.map(|(_, e)| e)
.filter(|e| e.subject == "Marked journal-me")
.collect();
assert_eq!(kept.len(), 2, "{kept:?}");
assert!(kept.iter().any(|e| e.journals == vec![number(&remote)]));
let (_, response) = call(
&admin,
"inbuxa:Journal/get",
json!({"ids": [remote.clone()]}),
)
.await;
let failures = &response["list"][0]["archiveFailures"];
assert_eq!(failures["count"], 1, "{response}");
assert_eq!(
failures["lastReason"],
if deleted {
"it wasn't delivered before leaving the queue"
} else {
"the archive refused it"
},
"{response}"
);
let (_, response) = call(
&admin,
"inbuxa:Journal/get",
json!({"ids": [local.clone()]}),
)
.await;
assert_eq!(response["list"][0]["archiveFailures"]["count"], 0);
// Delivered to an archive here: the report arrives, and nothing goes
// into the built-in journal for that journal
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
"To the vault",
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
let mut arrived = Vec::new();
for _ in 0..100 {
let (_, response) = call(
&vault,
"Email/query",
json!({"filter": {"subject": "Journal report: To the vault"}}),
)
.await;
arrived = response["ids"].as_array().cloned().unwrap_or_default();
if !arrived.is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
assert_eq!(arrived.len(), 1, "the report arrived");
assert!(entry_for(test, "To the vault").await.is_none());
assert!(
all_entries(test)
.await
.iter()
.all(|(_, e)| !e.subject.starts_with("Journal report")),
"reports aren't journaled"
);
let (_, response) = call(
&admin,
"inbuxa:Journal/get",
json!({"ids": [local.clone()]}),
)
.await;
assert_eq!(response["list"][0]["archiveFailures"]["count"], 0);
call(&admin, "inbuxa:MailRule/set", json!({"destroy": [rule]})).await;
call(
&admin,
"inbuxa:Journal/set",
json!({"destroy": [rules_only, local, remote]}),
)
.await;
inbuxa_features::journal::invalidate();
}
/// Phase 4: searching, reading and exporting over JMAP, by a Compliance
/// Officer, each recorded; administrators set journals up but don't read
/// them; the chain check.
pub async fn search(test: &mut TestServer) {
println!("Running journal search tests...");
let admin = test.account("[email protected]");
let sender = admin
.create_user_account(
"[email protected]",
"search-sender-secret-7301",
"Search sender",
&[],
vec![],
)
.await;
let (_, response) = call(
&sender,
"Identity/set",
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
)
.await;
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&sender,
"Mailbox/set",
json!({"create": {"m": {"name": "Search drafts"}}}),
)
.await;
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&admin,
"inbuxa:Journal/set",
json!({"create": {"s": {"name": "Search sender", "enabled": true, "direction": "any",
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30}}}),
)
.await;
let journal_id = response["created"]["s"]["id"]
.as_str()
.unwrap_or_else(|| panic!("{response}"))
.to_string();
inbuxa_features::journal::invalidate();
for subject in ["Budget draft", "Budget final", "Lunch"] {
let response = send(
&sender,
&identity,
&mailbox,
&["[email protected]"],
&["[email protected]"],
subject,
)
.await;
assert!(response["created"].get("s").is_some(), "{response}");
}
// Administrators set journals up but don't read them
let (name, response) = call(
&admin,
"inbuxa:JournalEntry/query",
json!({"filter": {"sender": "search-sender"}}),
)
.await;
assert_eq!(name, "error", "{response}");
// A Compliance Officer does
let mut officer_role = None;
for id in admin
.registry_query_ids(
ObjectType::Role,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new(),
)
.await
{
let role = admin.registry_get::<Role>(id).await;
if role.description == "Compliance Officer" && role.member_tenant_id.is_none() {
officer_role = Some(id);
}
}
let officer = admin
.create_user_account(
"[email protected]",
"journal-officer-secret-7302",
"Officer",
&[],
vec![],
)
.await;
admin
.registry_update_object(
ObjectType::Account,
officer.id(),
json!({Property::Roles: UserRoles::Custom(CustomRoles {
role_ids: Map::new(vec![officer_role.expect("the officer role")]),
})}),
)
.await;
let (_, response) = call(
&officer,
"inbuxa:JournalEntry/query",
json!({"filter": {"sender": "search-sender", "text": "budget"}, "calculateTotal": true}),
)
.await;
assert_eq!(response["total"], 2, "{response}");
let ids = response["ids"].clone();
let (_, response) = call(&officer, "inbuxa:JournalEntry/get", json!({"ids": ids})).await;
let list = response["list"].as_array().unwrap();
assert_eq!(list.len(), 2, "{response}");
assert_eq!(list[0]["subject"], "Budget final", "newest first");
assert_eq!(list[0]["direction"], "outgoing");
assert_eq!(list[0]["journalIds"], json!([journal_id]));
assert!(list[0]["report"].is_null(), "only when asked for");
let first = list[0]["id"].as_str().unwrap().to_string();
let (_, response) = call(
&officer,
"inbuxa:JournalEntry/get",
json!({"ids": [first.clone()], "properties": ["subject", "report"]}),
)
.await;
let report = response["list"][0]["report"].as_str().unwrap_or_default();
assert!(
report.contains("Subject: Journal report: Budget final"),
"{response}"
);
assert!(report.contains("Sender: [email protected]\r\n"));
let (_, response) = call(
&officer,
"inbuxa:JournalEntry/query",
json!({"filter": {"journalId": journal_id, "direction": "incoming"}}),
)
.await;
assert_eq!(response["ids"], json!([]), "{response}");
let (name, _) = call(
&officer,
"inbuxa:JournalEntry/query",
json!({"filter": {"colour": "red"}}),
)
.await;
assert_eq!(name, "error");
// Exports need a reason, and hold every report the filter matches
let (_, response) = call(
&officer,
"inbuxa:JournalExport/set",
json!({"create": {"x": {"filter": {"sender": "search-sender"}}}}),
)
.await;
assert_eq!(
response["notCreated"]["x"]["properties"],
json!(["reason"]),
"{response}"
);
let (_, response) = call(
&officer,
"inbuxa:JournalExport/set",
json!({"create": {"x": {"filter": {"sender": "search-sender"}, "reason": "Case 12"}}}),
)
.await;
let export = &response["created"]["x"];
assert_eq!(export["count"], 3, "{response}");
assert!(export["blobId"].as_str().is_some());
assert_eq!(export["sha256"].as_str().map(str::len), Some(64));
// The chain check, which the officer may run too
let (_, response) = call(
&officer,
"inbuxa:JournalVerification/set",
json!({"create": {"v": {}}}),
)
.await;
assert_eq!(response["created"]["v"]["verified"], true, "{response}");
// The officer changes no journals
let (name, _) = call(
&officer,
"inbuxa:Journal/set",
json!({"destroy": [journal_id.clone()]}),
)
.await;
assert_eq!(name, "error");
// Every search, listing, read, export and check is recorded
let (_, response) = call(
&admin,
"inbuxa:AuditEvent/query",
json!({"filter": {"targetKind": "inbuxa:JournalEntry", "actorId": officer.id_string()}}),
)
.await;
let ids = response["ids"].clone();
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
let details: Vec<String> = response["list"]
.as_array()
.unwrap()
.iter()
.map(|e| format!("{} {}", e["action"], e["details"]))
.collect();
for expected in [
"Searched the journal",
"Listed 2 journal entries",
"Read a journaled message from [email protected]",
"Exported 3 journal entries",
"verify",
] {
assert!(
details.iter().any(|d| d.contains(expected)),
"{expected}: {details:?}"
);
}
call(
&admin,
"inbuxa:Journal/set",
json!({"destroy": [journal_id]}),
)
.await;
inbuxa_features::journal::invalidate();
}
struct Raw(Vec<u8>);
impl Deserialize for Raw {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(Raw(bytes.to_vec()))
}
}
#[ignore]
#[tokio::test(flavor = "multi_thread")]
pub async fn journal_tests() {
let mut test = TestServerBuilder::new("journal_tests")
.await
.with_default_listeners()
.await
.build()
.await;
let admin = test.create_admin_account("[email protected]").await;
test.insert_account(admin);
self::test(&mut test).await;
self::archive(&mut test).await;
self::search(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();
}
}