test(e2e): full offline mail-spine E2E + injectable engine seam + Sync RPC

Add MailEngineOverrides so tests inject MockMailBackend/MockMessageSender into a
live daemon without production depending on mocks (default = real engines). Add
an authenticated Mail.Sync RPC (awaits completion) and wire CLI 'mail sync' to
it, off the ephemeral runner. New spine_e2e_test drives the whole spine over
gRPC offline: AddAccount -> Sync -> ListMessages/GetMessage -> MarkRead (with
MessageFlagsChanged on Subscribe) -> SendMessage (captured by the mock sender).

Refs #161
This commit is contained in:
James Maes committed 2026-07-27 01:26:26 -05:00
1 parent 03cbd68d49
commit dd10eda5f3
6 files changed
+970 -59

No files matched your search

+87 -41
View File
@@ -722,18 +722,39 @@ impl HeadlessRunner {
}
}
/// `mail sync`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Mail/Sync` API (backlog story 1.C.6, GH #161).
/// Replaces this command's previous local-ephemeral behavior (flipping
/// this runner's own throwaway `EventBus` status flag, which never
/// fetched a single real message) with a real inbound sync against the
/// daemon's persistent store -- the RPC awaits full completion before
/// returning, so a successful response's `synced_count` reflects
/// messages that are already visible via `mail list`/`mail read`.
async fn handle_sync(&self, json_mode: bool) -> String {
self.event_bus.process_command(CoreCommand::SyncAll);
if json_mode {
format_json(&json!({
"status": "sync_started",
"engine_status": format!("{:?}", self.event_bus.current_state().status)
}))
} else {
format!(
"Synchronization started. Engine status: {:?}",
self.event_bus.current_state().status
)
let mut client = match self.connect_mail_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.sync(nuncio_proto::v1::SyncRequest { account_id: None })
.await
{
Ok(response) => {
let synced_count = response.into_inner().synced_count;
if json_mode {
format_json(&json!({
"status": "sync_started",
"synced_count": synced_count,
}))
} else {
format!("Synchronization complete: {synced_count} message(s) synced")
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected sync: {status}"),
json_mode,
),
}
}
@@ -1368,26 +1389,16 @@ mod tests {
.await;
assert!(acct_show.contains("Account 'missing' not found"));
// Mail Noun Commands: `Sync` does not talk to the daemon (yet), so
// it is safe to exercise against this ephemeral local runner.
// `List`/`Read`/`Search`/`Mark`/`Send` (and `Folder::List`) are now
// real gRPC clients of the `nunciod` daemon's `Mail` API (backlog
// stories 1.C.4 / 1.C.5, GH #159 / GH #160) -- exactly like
// Mail Noun Commands: `Sync`/`List`/`Read`/`Search`/`Mark`/`Send`
// (and `Folder::List`) are all now real gRPC clients of the
// `nunciod` daemon's `Mail` API (backlog stories 1.C.4 / 1.C.5 /
// 1.C.6, GH #159 / GH #160 / GH #161) -- exactly like
// `Account::Add`/`List` and `System::Status` above, they must never
// run against this `ephemeral()`-constructed runner's production
// `SecretManager` or its (unreachable in CI) default gRPC address.
// They are exercised separately below via `ephemeral_with` +
// `SecretManager::mock()` against a live stub `Mail` gRPC server
// (`mail_rpcs_round_trip_over_grpc_to_a_stub_daemon`).
let mail_sync = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Sync,
},
true,
)
.await;
assert!(mail_sync.contains(r#""status":"sync_started""#));
// Cal Noun Commands
let cal_list = runner
@@ -1702,19 +1713,20 @@ mod tests {
assert!(list_out_text.contains("1 account(s) registered"));
}
/// Reference-client proof for backlog story 1.C.4 (GH #159): boots a
/// stub `nuncio.v1.Mail` gRPC server (mirroring the `Accounts` stub
/// pattern above) and drives the real `HeadlessRunner`'s `mail
/// list`/`mail read`/`mail mark`/`mail search`/`folder list` gRPC client
/// paths against it.
/// Reference-client proof for backlog stories 1.C.4 / 1.C.6 (GH #159 /
/// GH #161): boots a stub `nuncio.v1.Mail` gRPC server (mirroring the
/// `Accounts` stub pattern above) and drives the real `HeadlessRunner`'s
/// `mail sync`/`mail list`/`mail read`/`mail mark`/`mail search`/
/// `folder list` gRPC client paths against it.
///
/// Real persistence (`mark_read` actually flipping the flag in the
/// daemon's store, `list`/`get` returning REAL synced data,
/// `MessageFlagsChanged` streaming) is proven by `nunciod`'s own
/// `grpc::tests`; this test exists purely to prove the CLI's connect +
/// call + JSON-format happy path, its honest not-found error handling,
/// and that `mail mark` rejects an ambiguous `--read`/`--unread`
/// combination before ever dialing the daemon.
/// Real persistence (`sync` actually fetching and persisting messages,
/// `mark_read` actually flipping the flag in the daemon's store,
/// `list`/`get` returning REAL synced data, `MessageFlagsChanged`
/// streaming) is proven by `nunciod`'s own `grpc::tests`; this test
/// exists purely to prove the CLI's connect + call + JSON-format happy
/// path, its honest not-found error handling, and that `mail mark`
/// rejects an ambiguous `--read`/`--unread` combination before ever
/// dialing the daemon.
#[tokio::test]
async fn mail_rpcs_round_trip_over_grpc_to_a_stub_daemon() {
use nuncio_proto::v1::mail_server::{Mail as MailService, MailServer};
@@ -1722,7 +1734,8 @@ mod tests {
Folder as FolderProto, GetMessageRequest, GetMessageResponse, ListFoldersRequest,
ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, MarkReadRequest,
MarkReadResponse, Message as MessageProto, MessageSearchHit, SearchMessagesRequest,
SearchMessagesResponse, SendMessageRequest, SendMessageResponse,
SearchMessagesResponse, SendMessageRequest, SendMessageResponse, SyncRequest,
SyncResponse,
};
use std::sync::Mutex;
@@ -1743,13 +1756,14 @@ mod tests {
}
/// Minimal test-only stub of `nuncio.v1.Mail`: records the last
/// `MarkReadRequest` it received (so this test can assert on
/// exactly what the CLI sent over the wire) and otherwise returns
/// fixed responses.
/// `MarkReadRequest`/`SyncRequest` it received (so this test can
/// assert on exactly what the CLI sent over the wire) and otherwise
/// returns fixed responses.
#[derive(Default)]
struct StubMail {
last_mark_read: Arc<Mutex<Option<MarkReadRequest>>>,
last_send_message: Arc<Mutex<Option<SendMessageRequest>>>,
last_sync: Arc<Mutex<Option<SyncRequest>>>,
}
#[tonic::async_trait]
@@ -1836,11 +1850,21 @@ mod tests {
message_id: "sent-stub-1".to_string(),
}))
}
async fn sync(
&self,
request: tonic::Request<SyncRequest>,
) -> Result<tonic::Response<SyncResponse>, tonic::Status> {
let req = request.into_inner();
*self.last_sync.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(SyncResponse { synced_count: 2 }))
}
}
let stub = StubMail::default();
let probe = stub.last_mark_read.clone();
let send_probe = stub.last_send_message.clone();
let sync_probe = stub.last_sync.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
@@ -1858,6 +1882,28 @@ mod tests {
.await
.expect("ephemeral runner initializes");
// `mail sync`: proves the CLI is a real gRPC client of `Mail/Sync`
// (backlog story 1.C.6, GH #161) -- it sends a `SyncRequest` with no
// `account_id` (sync every account) and reports the daemon's real
// `synced_count` in its output, rather than fabricating a status by
// only flipping this runner's own throwaway local `EventBus`.
let mail_sync = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Sync,
},
true,
)
.await;
assert!(mail_sync.contains(r#""status":"sync_started""#));
assert!(mail_sync.contains(r#""synced_count":2"#));
let recorded_sync = sync_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a sync request");
assert_eq!(recorded_sync.account_id, None);
// `folder list`
let folder_list = runner
.execute_command(
@@ -255,6 +255,15 @@ service Mail {
// as a `Status::internal` (or `Status::invalid_argument` for a malformed
// request) error, never a fabricated `SendMessageResponse`.
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
// Sync triggers a real inbound mail synchronization (backlog story 1.C.6,
// GH #161): fetches folders and messages from the account's real mail
// backend and persists them via the store, exactly as `nunciod`'s
// internal sync command loop does, EXCEPT this awaits full completion
// before returning -- a successful response guarantees the synced
// messages are already visible to `ListMessages`/`GetMessage`. This is
// the authenticated, over-the-wire replacement for reaching into daemon
// internals to trigger a sync (see GH #165).
rpc Sync(SyncRequest) returns (SyncResponse);
}
// Mirrors `nuncio_core::model::Attachment`.
@@ -401,3 +410,15 @@ message SendMessageResponse {
// written back into the synced message store by this RPC.
string message_id = 1;
}
// SyncRequest. `account_id` mirrors `CoreCommand::SyncAccount`/`SyncAll`:
// absent/empty means synchronize every configured account; set means
// synchronize just that one account.
message SyncRequest {
optional string account_id = 1;
}
// SyncResponse reports how many messages were synchronized.
message SyncResponse {
uint64 synced_count = 1;
}
+435 -11
View File
@@ -8,6 +8,7 @@
//! transport is out of scope here and lands in a later story.
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
use nuncio_mail::{MailBackend, MessageSender};
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
use nuncio_proto::v1::event::Kind;
use nuncio_proto::v1::mail_server::{Mail, MailServer};
@@ -21,7 +22,7 @@ use nuncio_proto::v1::{
ListMessagesResponse, MarkReadRequest, MarkReadResponse, Message as MessageProto,
MessageFlagsChanged, MessageSearchHit, SearchMessagesRequest, SearchMessagesResponse,
SendMessageRequest, SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted,
SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
SyncRequest, SyncResponse, SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::search::SearchEngine;
@@ -411,6 +412,43 @@ fn map_folder_to_proto(folder: nuncio_core::model::Folder) -> FolderProto {
}
}
/// Test-only injection point for the daemon's inbound sync and outbound
/// send engines, threaded through the daemon's runtime wiring (backlog
/// story 1.C.6, GH #161: the full-daemon offline spine E2E test).
///
/// Production (`nunciod::main`, and every existing caller of [`serve`] /
/// [`serve_on_listener`]) never constructs a non-default instance:
/// [`Default`] yields every field `None`, which routes `Mail/Sync` and
/// `Mail/SendMessage` through the exact same real production entry points
/// (`nunciod::sync::run_all_accounts_sync` / `run_account_sync`,
/// `nunciod::send::send_message_for_account`) as before this override
/// existed. Production code depends only on the `nuncio_mail::{MailBackend,
/// MessageSender}` trait objects here -- never on `nuncio_mail`'s mock
/// types -- so this seam never makes production depend on a test double.
///
/// Only a full-daemon E2E test supplies `Some(..)`, standing in a
/// [`nuncio_mail::MockMailBackend`] / [`nuncio_mail::MockMessageSender`]
/// for real network I/O, and drives the override through the exact same
/// authenticated gRPC API a real client uses.
#[derive(Clone, Default)]
pub struct MailEngineOverrides {
/// When `Some`, `Mail/Sync` fetches from this backend instead of
/// building a real per-account engine from keyring credentials.
pub mail_backend: Option<Arc<dyn MailBackend>>,
/// When `Some`, `Mail/SendMessage` sends through this sender instead of
/// building a real `SmtpTransportEngine` from keyring credentials.
pub message_sender: Option<Arc<dyn MessageSender>>,
}
impl std::fmt::Debug for MailEngineOverrides {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MailEngineOverrides")
.field("mail_backend", &self.mail_backend.is_some())
.field("message_sender", &self.message_sender.is_some())
.finish()
}
}
/// `nuncio.v1.Mail` gRPC service implementation backed by the daemon's live
/// [`DatabaseEngine`] read/mark methods and [`SearchEngine`] FTS index
/// (backlog story 1.C.4, GH #159), and (backlog story 1.C.5, GH #160) the
@@ -423,11 +461,13 @@ fn map_folder_to_proto(folder: nuncio_core::model::Folder) -> FolderProto {
/// (`SendMessage`) -- as opposed to the CLI's previous local ephemeral
/// `HeadlessRunner` database, which was thrown away when the CLI process
/// exited, and its previous fabricated "Message sent" output, which never
/// actually dialed an SMTP server.
/// actually dialed an SMTP server. `Sync` (backlog story 1.C.6, GH #161)
/// triggers a real inbound sync over the same authenticated API.
struct MailGrpcService {
db: Arc<DatabaseEngine>,
event_bus: Arc<EventBus>,
secrets: Arc<SecretManager>,
overrides: MailEngineOverrides,
}
#[tonic::async_trait]
@@ -553,6 +593,13 @@ impl Mail for MailGrpcService {
/// genuinely accepted the message -- a resolution or transport failure
/// surfaces as `Status::internal`/`Status::invalid_argument`, never a
/// fabricated `SendMessageResponse`.
///
/// When [`MailEngineOverrides::message_sender`] is injected (backlog
/// story 1.C.6, GH #161), sends through it instead via
/// [`crate::send::send_message_with_injected_sender`] -- no keyring
/// lookup, no real SMTP transport -- so a full-daemon E2E test can
/// assert on the exact outbound message an injected
/// [`nuncio_mail::MockMessageSender`] captured.
async fn send_message(
&self,
request: Request<SendMessageRequest>,
@@ -578,12 +625,51 @@ impl Mail for MailGrpcService {
.collect(),
};
let message_id = crate::send::send_message_for_account(&self.db, &self.secrets, compose)
.await
.map_err(|e| Status::internal(format!("failed to send message: {e}")))?;
let message_id = if let Some(sender) = &self.overrides.message_sender {
crate::send::send_message_with_injected_sender(&self.db, sender.as_ref(), compose)
.await
.map_err(|e| Status::internal(format!("failed to send message: {e}")))?
} else {
crate::send::send_message_for_account(&self.db, &self.secrets, compose)
.await
.map_err(|e| Status::internal(format!("failed to send message: {e}")))?
};
Ok(Response::new(SendMessageResponse { message_id }))
}
/// Sync (backlog story 1.C.6, GH #161): triggers a real inbound mail
/// synchronization and awaits full completion before returning, so a
/// successful response guarantees the synced messages are already
/// visible to `ListMessages`/`GetMessage`. `account_id` mirrors
/// `CoreCommand::SyncAccount`/`SyncAll`: `Some` scopes the sync to one
/// account, `None` syncs every configured account.
///
/// When [`MailEngineOverrides::mail_backend`] is injected, fetches from
/// it via [`crate::sync::sync_with_backend`] instead of resolving a
/// real per-account engine from keyring credentials -- this is what
/// lets a full-daemon E2E test drive a real inbound sync entirely over
/// this authenticated gRPC API with no live network, exactly as a real
/// client would trigger it (GH #165: no un-intercepted back door).
async fn sync(&self, request: Request<SyncRequest>) -> Result<Response<SyncResponse>, Status> {
let account_id = request.into_inner().account_id;
let synced = if let Some(backend) = &self.overrides.mail_backend {
crate::sync::sync_with_backend(&self.db, &self.event_bus, backend.as_ref(), account_id)
.await
.map_err(|e| Status::internal(format!("sync failed: {e}")))?
} else if let Some(account_id) = account_id {
crate::sync::run_account_sync(&self.db, &self.secrets, &self.event_bus, &account_id)
.await
.map_err(|e| Status::internal(format!("sync failed: {e}")))?
} else {
crate::sync::run_all_accounts_sync(&self.db, &self.secrets, &self.event_bus).await
};
Ok(Response::new(SyncResponse {
synced_count: synced as u64,
}))
}
}
/// Bearer-token authentication interceptor for the loopback `nuncio.v1` gRPC
@@ -679,6 +765,32 @@ pub async fn serve_on_listener(
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
) -> Result<(), GrpcServeError> {
serve_on_listener_with_overrides(
listener,
event_bus,
db,
secrets,
token,
MailEngineOverrides::default(),
)
.await
}
/// Identical to [`serve_on_listener`], except the `nuncio.v1.Mail` service's
/// inbound sync and outbound send engines can be overridden (backlog story
/// 1.C.6, GH #161). [`serve_on_listener`] is simply this function called
/// with `MailEngineOverrides::default()` (i.e. every field `None`, which is
/// production's exact prior behavior); this function exists so a
/// full-daemon offline E2E test can supply `Some(..)` without changing
/// [`serve_on_listener`]'s signature for its many existing callers.
pub async fn serve_on_listener_with_overrides(
listener: TcpListener,
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
overrides: MailEngineOverrides,
) -> Result<(), GrpcServeError> {
let token: Arc<str> = token.into();
@@ -695,14 +807,15 @@ pub async fn serve_on_listener(
let accounts_interceptor = BearerAuthInterceptor::new(token.clone());
let accounts_svc = AccountsServer::with_interceptor(accounts_service, accounts_interceptor);
// Mail (backlog story 1.C.4, GH #159; SendMessage: 1.C.5, GH #160):
// mounted behind its own `BearerAuthInterceptor`, exactly like `System`
// and `Accounts` above -- see the hard invariant documented on this
// function's doc comment (GH #165).
// Mail (backlog story 1.C.4, GH #159; SendMessage: 1.C.5, GH #160;
// Sync: 1.C.6, GH #161): mounted behind its own `BearerAuthInterceptor`,
// exactly like `System` and `Accounts` above -- see the hard invariant
// documented on this function's doc comment (GH #165).
let mail_service = MailGrpcService {
db,
event_bus,
secrets,
overrides,
};
let mail_interceptor = BearerAuthInterceptor::new(token);
let mail_svc = MailServer::with_interceptor(mail_service, mail_interceptor);
@@ -749,6 +862,29 @@ mod tests {
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
token: &str,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
spawn_test_server_with_overrides(
event_bus,
db,
secrets,
token,
MailEngineOverrides::default(),
)
.await
}
/// Spawns a test server on an ephemeral loopback port backed by the
/// given `db`/`secrets`/`overrides` (backlog story 1.C.6, GH #161): the
/// helper every test that injects a [`MailEngineOverrides::mail_backend`]
/// / [`MailEngineOverrides::message_sender`] uses to prove `Sync` /
/// `SendMessage` drive an injected test double over the real
/// authenticated gRPC API.
async fn spawn_test_server_with_overrides(
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
token: &str,
overrides: MailEngineOverrides,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -756,7 +892,10 @@ mod tests {
let addr = listener.local_addr().expect("listener has local addr");
let token = token.to_string();
let handle = tokio::spawn(async move {
let _ = serve_on_listener(listener, event_bus, db, secrets, token).await;
let _ = serve_on_listener_with_overrides(
listener, event_bus, db, secrets, token, overrides,
)
.await;
});
(addr, handle)
}
@@ -1334,7 +1473,7 @@ mod tests {
use nuncio_proto::v1::mail_client::MailClient;
use nuncio_proto::v1::{
GetMessageRequest, ListFoldersRequest, ListMessagesRequest, MarkReadRequest,
SearchMessagesRequest, SendMessageRequest,
SearchMessagesRequest, SendMessageRequest, SyncRequest,
};
fn sample_email(
@@ -1422,6 +1561,12 @@ mod tests {
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.sync(SyncRequest { account_id: None })
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
#[tokio::test]
@@ -1721,4 +1866,283 @@ mod tests {
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("failed to send message"));
}
// ---- Sync (backlog story 1.C.6, GH #161) ----
/// With no `MailEngineOverrides` and no configured accounts, `Sync`
/// exercises the real production `run_all_accounts_sync` path and
/// honestly reports zero messages synced (never fabricating a nonzero
/// count).
#[tokio::test]
async fn sync_without_override_and_without_accounts_reports_zero_synced() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.sync(authed_bearer_request(SyncRequest { account_id: None }))
.await
.expect("sync succeeds")
.into_inner();
assert_eq!(response.synced_count, 0);
}
/// With no `MailEngineOverrides`, requesting a sync for an account_id
/// that is not configured exercises the real production
/// `run_account_sync` path and surfaces an honest `Status::internal`
/// error rather than a fabricated success.
#[tokio::test]
async fn sync_without_override_reports_error_for_unknown_account() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.sync(authed_bearer_request(SyncRequest {
account_id: Some("acct-does-not-exist".to_string()),
}))
.await
.expect_err("unknown account must be rejected");
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("acct-does-not-exist"));
}
/// Backlog story 1.C.6 (GH #161): with a [`MailEngineOverrides::mail_backend`]
/// injected, `Sync` fetches from it (via `sync_with_backend`) instead of
/// resolving a real per-account engine from keyring credentials, and
/// awaits full completion before returning -- proving a caller can
/// immediately `ListMessages`/`GetMessage` the synced data with no
/// fixed sleep. This is the exact mechanism the full-daemon offline
/// spine E2E test (`spine_e2e_test.rs`) uses.
#[tokio::test]
async fn sync_with_injected_backend_persists_and_reports_count_deterministically() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let mock_backend = nuncio_mail::MockMailBackend::new();
mock_backend.add_folder(nuncio_core::model::Folder {
id: "inbox".to_string(),
name: "Inbox".to_string(),
total_messages: 1,
unread_messages: 1,
});
mock_backend.add_message(sample_email(
"msg-sync-injected-1",
"inbox",
"Injected Sync Subject",
"Injected sync body",
));
let overrides = MailEngineOverrides {
mail_backend: Some(Arc::new(mock_backend)),
message_sender: None,
};
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
secrets,
"correct-token",
overrides,
)
.await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.sync(authed_bearer_request(SyncRequest { account_id: None }))
.await
.expect("sync succeeds")
.into_inner();
assert_eq!(response.synced_count, 1);
// Immediately visible, no fixed sleep: `Sync` only returns once the
// fetch-and-persist work has fully completed.
let fetched = client
.get_message(authed_bearer_request(GetMessageRequest {
message_id: "msg-sync-injected-1".to_string(),
}))
.await
.expect("get_message succeeds")
.into_inner()
.message
.expect("message present in response");
assert_eq!(fetched.subject, "Injected Sync Subject");
assert_eq!(fetched.body_plain.as_deref(), Some("Injected sync body"));
}
/// Backlog story 1.C.6 (GH #161): with a [`MailEngineOverrides::message_sender`]
/// injected, `SendMessage` sends through it (via
/// `send_message_with_injected_sender`) instead of building a real SMTP
/// transport, and the injected mock captures the EXACT outbound message
/// -- recipient, subject, and body -- the caller sent over the wire.
#[tokio::test]
async fn send_message_with_injected_sender_captures_exact_outbound_message() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let config = nuncio_core::AccountConfig {
id: "acct-injected-send-1".to_string(),
name: "Injected Send Test Account".to_string(),
email_address: "sender@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
keyring_secret_key: "nuncio/acct-injected-send-1".to_string(),
sync_interval_secs: 60,
};
// Deliberately never stores a keyring credential -- this path must
// never need one.
db.save_account(&config).await.expect("save account");
let mock_sender = nuncio_mail::MockMessageSender::new();
let sent_probe = mock_sender.clone();
let overrides = MailEngineOverrides {
mail_backend: None,
message_sender: Some(Arc::new(mock_sender)),
};
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
secrets,
"correct-token",
overrides,
)
.await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.send_message(authed_bearer_request(SendMessageRequest {
to: "alice@nuncio.mx".to_string(),
cc: Some("carol@nuncio.mx".to_string()),
subject: "Injected Send Subject".to_string(),
body_text: "Injected send body".to_string(),
body_html: None,
attachments: Vec::new(),
}))
.await
.expect("send_message succeeds")
.into_inner();
assert!(response.message_id.starts_with("sent-"));
let sent = sent_probe.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].from, "sender@nuncio.mx");
assert_eq!(sent[0].to, "alice@nuncio.mx");
assert_eq!(sent[0].cc.as_deref(), Some("carol@nuncio.mx"));
assert_eq!(sent[0].subject, "Injected Send Subject");
assert_eq!(sent[0].body_plain.as_deref(), Some("Injected send body"));
}
/// Backlog story 1.C.6 (GH #161): with a [`MailEngineOverrides::mail_backend`]
/// injected and configured to simulate a failure, `Sync` surfaces the
/// genuine `sync_with_backend` error as `Status::internal` rather than
/// a fabricated success.
#[tokio::test]
async fn sync_with_injected_backend_reports_error_when_backend_fails() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let mock_backend = nuncio_mail::MockMailBackend::new();
mock_backend.set_should_fail(true);
let overrides = MailEngineOverrides {
mail_backend: Some(Arc::new(mock_backend)),
message_sender: None,
};
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
secrets,
"correct-token",
overrides,
)
.await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.sync(authed_bearer_request(SyncRequest { account_id: None }))
.await
.expect_err("a failing injected backend must be rejected honestly");
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("sync failed"));
}
/// Backlog story 1.C.6 (GH #161): with a [`MailEngineOverrides::message_sender`]
/// injected and configured to simulate a transport failure,
/// `SendMessage` surfaces the genuine `send_message_with_injected_sender`
/// error as `Status::internal` rather than a fabricated success.
#[tokio::test]
async fn send_message_with_injected_sender_reports_error_when_transport_fails() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let config = nuncio_core::AccountConfig {
id: "acct-injected-send-fail-1".to_string(),
name: "Injected Send Failure Test Account".to_string(),
email_address: "sender@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
keyring_secret_key: "nuncio/acct-injected-send-fail-1".to_string(),
sync_interval_secs: 60,
};
db.save_account(&config).await.expect("save account");
let mock_sender = nuncio_mail::MockMessageSender::new();
mock_sender.set_should_fail(true);
let overrides = MailEngineOverrides {
mail_backend: None,
message_sender: Some(Arc::new(mock_sender)),
};
let (addr, _handle) = spawn_test_server_with_overrides(
Arc::new(EventBus::new()),
db,
secrets,
"correct-token",
overrides,
)
.await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.send_message(authed_bearer_request(valid_send_message_request()))
.await
.expect_err("a failing injected sender must be rejected honestly");
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("failed to send message"));
}
}
+95
View File
@@ -171,6 +171,36 @@ pub async fn send_message_for_account(
Ok(generate_message_id())
}
/// Test/E2E entry point for the `SendMessage` RPC when a
/// [`nunciod::grpc::MailEngineOverrides::message_sender`] is injected
/// (backlog story 1.C.6, GH #161): resolves ONLY the sending account's
/// `From:` address from the store -- no keyring lookup, no real SMTP
/// transport construction -- and sends `request` through the given
/// `sender` (e.g. a [`nuncio_mail::MockMessageSender`] in a full-daemon
/// offline E2E test). Mirrors [`send_message_for_account`] in every other
/// respect: returns the generated message id ONLY on genuine acceptance by
/// `sender`, never a fabricated success.
pub async fn send_message_with_injected_sender(
db: &DatabaseEngine,
sender: &dyn MessageSender,
request: ComposeRequest,
) -> Result<String, SendError> {
let config = resolve_sending_account(db).await?;
let outbound = OutboundMessage {
from: config.email_address,
to: request.to,
cc: request.cc,
subject: request.subject,
body_plain: request.body_text,
body_html: request.body_html,
attachments: request.attachments,
};
send_with_sender(sender, &outbound).await?;
Ok(generate_message_id())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -334,4 +364,69 @@ mod tests {
assert!(id.starts_with("sent-"));
assert!(id.len() > "sent-".len());
}
/// Backlog story 1.C.6 (GH #161): proves `send_message_with_injected_sender`
/// resolves the `From:` address from the store and records the exact
/// outbound message on the injected sender, WITHOUT ever touching the
/// keyring (no credential is even stored for this account).
#[tokio::test]
async fn send_message_with_injected_sender_records_exact_message_without_touching_vault() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let config = sample_account("acct-injected-sender");
db.save_account(&config).await.expect("save account");
// Deliberately never stores a keyring credential -- this path must
// never need one.
let mock = MockMessageSender::new();
let message_id = send_message_with_injected_sender(
&db,
&mock,
ComposeRequest {
to: "bob@nuncio.mx".to_string(),
cc: Some("carol@nuncio.mx".to_string()),
subject: "Injected Sender Test".to_string(),
body_text: Some("Body via injected sender.".to_string()),
body_html: None,
attachments: Vec::new(),
},
)
.await
.expect("send succeeds via injected sender");
assert!(message_id.starts_with("sent-"));
let sent = mock.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].from, config.email_address);
assert_eq!(sent[0].to, "bob@nuncio.mx");
assert_eq!(sent[0].cc.as_deref(), Some("carol@nuncio.mx"));
assert_eq!(sent[0].subject, "Injected Sender Test");
}
#[tokio::test]
async fn send_message_with_injected_sender_reports_honest_error_when_no_account_configured() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let mock = MockMessageSender::new();
let err = send_message_with_injected_sender(
&db,
&mock,
ComposeRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Hi".to_string(),
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
},
)
.await
.expect_err("no configured account must fail");
assert!(matches!(err, SendError::NoAccountConfigured));
assert!(mock.sent_messages().is_empty());
}
}
+16 -7
View File
@@ -11,7 +11,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_cli::{Commands, HeadlessRunner, MailSubcommand, SystemSubcommand};
use nuncio_cli::{AccountSubcommand, Commands, HeadlessRunner, SystemSubcommand};
use nuncio_core::ipc::{IpcClient, IpcDaemonServer};
use nuncio_core::{CoreCommand, EventBus};
use nuncio_store::db::DatabaseEngine;
@@ -82,20 +82,29 @@ async fn e2e_multi_shell_daemon_concurrency_test() {
// 5. Independently, drive the real in-workspace `nuncio-cli` reference
// shell (its own ephemeral local engine, per its documented headless
// design) to confirm a genuine presentation-shell client still
// round-trips full commands correctly end-to-end.
// round-trips full commands correctly end-to-end. `Account::Show`
// reads this runner's own ephemeral local `db` (out of scope for any
// gRPC-backed backlog story), so it is safe to exercise without a live
// `nunciod` gRPC daemon. `mail sync`, `mail list/read/mark/search/send`,
// and `account add/list` are now real gRPC clients of the daemon
// (backlog stories 1.C.1 through 1.C.6, GH #156-#161) and are exercised
// against a real `nunciod` gRPC server by `nuncio-cli`'s own test suite
// and by `spine_e2e_test.rs`'s full offline spine E2E, rather than here.
let cli_runner = HeadlessRunner::ephemeral()
.await
.expect("cli headless runner initializes");
let cli_sync = cli_runner
let cli_show = cli_runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Sync,
&Commands::Account {
action: AccountSubcommand::Show {
id: "missing".to_string(),
},
},
true,
false,
)
.await;
assert!(cli_sync.contains(r#""status":"sync_started""#));
assert!(cli_show.contains("Account 'missing' not found"));
// `system status` no longer reflects this `HeadlessRunner`'s own local
// ephemeral state: it is a real gRPC client of the `nunciod` daemon
+316
View File
@@ -0,0 +1,316 @@
//! Capstone offline end-to-end test for the mail spine (backlog story
//! 1.C.6, GH #161).
//!
//! Boots a real `nunciod` daemon gRPC server (`nunciod::grpc::
//! serve_on_listener_with_overrides`) on an ephemeral loopback port, backed
//! by a real temp file-backed [`DatabaseEngine`] and a [`SecretManager::mock`]
//! vault (never the real OS keyring), with an injected
//! [`nuncio_mail::MockMailBackend`] / [`nuncio_mail::MockMessageSender`]
//! standing in for real network I/O (backlog story 1.C.6's
//! [`nunciod::grpc::MailEngineOverrides`] injection seam).
//!
//! Then, driving ONLY the authenticated `nuncio-proto` gRPC clients
//! (`connect_system`/`connect_accounts`/`connect_mail`) with the shared
//! bearer token -- never reaching into daemon internals -- this proves the
//! complete spine end-to-end:
//! a. `AddAccount` persists (`ListAccounts` reflects it).
//! b. `Mail/Sync` (backlog story 1.C.6) triggers a real inbound sync
//! against the injected mock backend and awaits full completion, so
//! the synced messages are immediately visible to
//! `ListMessages`/`GetMessage`.
//! c. `MarkRead` persists (a second `GetMessage` reflects the flip) and a
//! `MessageFlagsChanged` event arrives on a live `System/Subscribe`
//! stream.
//! d. `SendMessage` sends through the injected `MockMessageSender`, which
//! captures the EXACT outbound message (recipients/subject/body).
//!
//! Deterministic and fully offline: no fixed sleeps (the `Sync` RPC awaits
//! completion before returning, and the `Subscribe` stream is opened and
//! its registration awaited BEFORE the event-producing call that follows
//! it, exactly as `nunciod::grpc`'s own tests do), no real network, no real
//! OS keyring.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_core::model::{Email, Folder};
use nuncio_core::EventBus;
use nuncio_mail::{MockMailBackend, MockMessageSender};
use nuncio_proto::v1::event::Kind;
use nuncio_proto::v1::{
AccountConfig, AccountProtocol, AddAccountRequest, GetMessageRequest, ListAccountsRequest,
ListMessagesRequest, MarkReadRequest, SendMessageRequest, SyncRequest, TlsMode,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{serve_on_listener_with_overrides, MailEngineOverrides};
use std::sync::Arc;
use tokio::net::TcpListener;
const ACCOUNT_ID: &str = "acct-spine-e2e-1";
const ACCOUNT_EMAIL: &str = "spine-e2e@nuncio.mx";
const KEYRING_KEY: &str = "nuncio/acct-spine-e2e-1";
fn sample_account_config() -> AccountConfig {
AccountConfig {
id: ACCOUNT_ID.to_string(),
name: "Spine E2E Test Account".to_string(),
email_address: ACCOUNT_EMAIL.to_string(),
protocol: AccountProtocol::ImapSmtp.into(),
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
use_tls: true,
imap_tls_mode: TlsMode::ImplicitTls.into(),
smtp_tls_mode: TlsMode::ImplicitTls.into(),
keyring_secret_key: KEYRING_KEY.to_string(),
sync_interval_secs: 60,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
}
}
fn mock_inbound_email(id: &str, subject: &str, body: &str) -> Email {
Email {
id: id.to_string(),
account_id: ACCOUNT_ID.to_string(),
folder_id: "inbox".to_string(),
subject: subject.to_string(),
sender: "alice@nuncio.mx".to_string(),
recipient: ACCOUNT_EMAIL.to_string(),
received_at: 1_700_000_000,
read: false,
body_plain: Some(body.to_string()),
body_html: None,
attachments: Vec::new(),
}
}
/// Boots the real daemon gRPC server (all three `nuncio.v1` services,
/// authenticated by a bearer token minted from `secrets`) on an ephemeral
/// loopback port, backed by a real temp file-backed [`DatabaseEngine`] and
/// the given [`MailEngineOverrides`]. Returns the server address and the
/// minted token; the `TempDir` guard and `DatabaseEngine` handle are
/// returned too so the caller controls their lifetime for the whole test.
async fn boot_daemon(
secrets: Arc<SecretManager>,
overrides: MailEngineOverrides,
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
let db_path = dir.path().join("spine_e2e.db");
let db = Arc::new(
DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("open temp file-backed database"),
);
let token_bytes = secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mint gRPC bearer token from mock vault");
let token = hex::encode(token_bytes);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let event_bus = Arc::new(EventBus::new());
let server_secrets = secrets.clone();
let server_token = token.clone();
tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener,
event_bus,
db,
server_secrets,
server_token,
overrides,
)
.await;
});
(addr, token, dir)
}
/// The full offline, deterministic end-to-end proof of the mail spine
/// (backlog story 1.C.6, GH #161): add an account, sync inbound mail, list
/// and read it, mark it read (observing the change stream live), and send
/// an outbound message -- ALL over the authenticated `nuncio.v1` gRPC API
/// against a live in-process daemon, with protocol I/O fully mocked.
#[tokio::test]
async fn full_mail_spine_round_trips_over_authenticated_grpc_with_mocked_protocol_io() {
// ---- Arrange: one shared mock vault, an injected mock backend/sender ----
let secrets = Arc::new(SecretManager::mock());
let mock_backend = MockMailBackend::new();
mock_backend.add_folder(Folder {
id: "inbox".to_string(),
name: "Inbox".to_string(),
total_messages: 2,
unread_messages: 2,
});
mock_backend.add_message(mock_inbound_email(
"msg-spine-1",
"Welcome to Nuncio",
"This is the first synced message.",
));
mock_backend.add_message(mock_inbound_email(
"msg-spine-2",
"Second Message",
"This is the second synced message.",
));
let mock_sender = MockMessageSender::new();
// Kept as a separate handle so assertions below can inspect what the
// daemon sent without going through gRPC (the mock IS the "real world"
// stand-in here, not something under test).
let sent_messages_probe = mock_sender.clone();
let overrides = MailEngineOverrides {
mail_backend: Some(Arc::new(mock_backend)),
message_sender: Some(Arc::new(mock_sender)),
};
let (addr, token, _db_dir) = boot_daemon(secrets, overrides).await;
let addr_str = addr.to_string();
let mut system_client = nuncio_proto::client::connect_system(&addr_str, &token)
.await
.expect("system client connects");
let mut accounts_client = nuncio_proto::client::connect_accounts(&addr_str, &token)
.await
.expect("accounts client connects");
let mut mail_client = nuncio_proto::client::connect_mail(&addr_str, &token)
.await
.expect("mail client connects");
// ---- (a) AddAccount persists, confirmed via ListAccounts ----
let add_response = accounts_client
.add_account(AddAccountRequest {
config: Some(sample_account_config()),
password: "spine-e2e-secret-password".to_string(),
})
.await
.expect("add_account succeeds")
.into_inner();
assert_eq!(add_response.id, ACCOUNT_ID);
let list_response = accounts_client
.list_accounts(ListAccountsRequest {})
.await
.expect("list_accounts succeeds")
.into_inner();
assert_eq!(list_response.accounts.len(), 1);
assert_eq!(list_response.accounts[0].id, ACCOUNT_ID);
assert_eq!(list_response.accounts[0].email_address, ACCOUNT_EMAIL);
// ---- (b) Mail/Sync triggers a real inbound sync against the mock ----
// backend and awaits full completion -- no fixed sleep is needed before
// the ListMessages/GetMessage calls immediately below.
let sync_response = mail_client
.sync(SyncRequest { account_id: None })
.await
.expect("sync succeeds")
.into_inner();
assert_eq!(sync_response.synced_count, 2);
let list_messages_response = mail_client
.list_messages(ListMessagesRequest {
folder_id: "inbox".to_string(),
limit: 0,
})
.await
.expect("list_messages succeeds")
.into_inner();
assert_eq!(list_messages_response.messages.len(), 2);
let synced_ids: Vec<&str> = list_messages_response
.messages
.iter()
.map(|m| m.id.as_str())
.collect();
assert!(synced_ids.contains(&"msg-spine-1"));
assert!(synced_ids.contains(&"msg-spine-2"));
let get_message_response = mail_client
.get_message(GetMessageRequest {
message_id: "msg-spine-1".to_string(),
})
.await
.expect("get_message succeeds")
.into_inner();
let synced_message = get_message_response
.message
.expect("message present in response");
assert_eq!(synced_message.subject, "Welcome to Nuncio");
assert_eq!(
synced_message.body_plain.as_deref(),
Some("This is the first synced message.")
);
assert!(!synced_message.read);
// ---- (c) MarkRead persists, and streams on System/Subscribe ----
// Subscribing BEFORE calling MarkRead guarantees (by the server's own
// documented contract, see `nunciod::grpc::SystemGrpcService::subscribe`)
// that once this `.subscribe()` call resolves, any event published
// afterwards -- including the one MarkRead is about to publish -- is
// observed on this stream. No fixed sleep is needed.
let mut event_stream = nuncio_proto::client::subscribe_events(&mut system_client)
.await
.expect("subscribe succeeds");
mail_client
.mark_read(MarkReadRequest {
message_id: "msg-spine-1".to_string(),
read: true,
})
.await
.expect("mark_read succeeds");
let refetched = mail_client
.get_message(GetMessageRequest {
message_id: "msg-spine-1".to_string(),
})
.await
.expect("get_message succeeds after mark_read")
.into_inner()
.message
.expect("message present in response");
assert!(refetched.read);
let event = event_stream
.message()
.await
.expect("stream yields without a transport error")
.expect("stream produces an event rather than ending");
match event.kind {
Some(Kind::MessageFlagsChanged(changed)) => {
assert_eq!(changed.message_id, "msg-spine-1");
assert!(changed.read);
}
other => panic!("expected a mapped MessageFlagsChanged event, got {other:?}"),
}
// ---- (d) SendMessage sends through the injected MockMessageSender ----
let send_response = mail_client
.send_message(SendMessageRequest {
to: "bob@nuncio.mx".to_string(),
cc: Some("carol@nuncio.mx".to_string()),
subject: "Quarterly Roadmap".to_string(),
body_text: "Let's discuss the roadmap.".to_string(),
body_html: None,
attachments: Vec::new(),
})
.await
.expect("send_message succeeds")
.into_inner();
assert!(send_response.message_id.starts_with("sent-"));
let sent = sent_messages_probe.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].from, ACCOUNT_EMAIL);
assert_eq!(sent[0].to, "bob@nuncio.mx");
assert_eq!(sent[0].cc.as_deref(), Some("carol@nuncio.mx"));
assert_eq!(sent[0].subject, "Quarterly Roadmap");
assert_eq!(
sent[0].body_plain.as_deref(),
Some("Let's discuss the roadmap.")
);
}