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:
6 files changed
+970
-59
No files matched your search
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user