diff --git a/Cargo.lock b/Cargo.lock index df41e19..f94cbed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2594,6 +2594,7 @@ dependencies = [ name = "nuncio-proto" version = "0.1.0" dependencies = [ + "bytes", "prost", "protoc-bin-vendored", "thiserror 1.0.69", diff --git a/crates/nuncio-proto/Cargo.toml b/crates/nuncio-proto/Cargo.toml index ccb1ec7..71359e1 100644 --- a/crates/nuncio-proto/Cargo.toml +++ b/crates/nuncio-proto/Cargo.toml @@ -13,6 +13,7 @@ workspace = true tonic.workspace = true tonic-prost.workspace = true prost.workspace = true +bytes.workspace = true thiserror.workspace = true [build-dependencies] diff --git a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin index 1f17b04..850edc6 100644 Binary files a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin and b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin differ diff --git a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto index f120980..171b418 100644 --- a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto +++ b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto @@ -2,6 +2,64 @@ syntax = "proto3"; package nuncio.v1; +// ErrorReason is the stable, machine-readable taxonomy of failure conditions +// the daemon reports to clients. It is carried inside an `ErrorInfo` in a +// failed RPC's gRPC status-details trailer so a client can branch on a +// discriminant instead of string-matching human-readable (English) error +// text. Each reason maps to a fixed canonical gRPC status code (see the +// server-side helper that builds the `Status`), so the transport-level code +// and this application-level reason always agree. +// +// Values are append-only: never renumber or repurpose an existing value. +// New failure conditions get a new value; `ERROR_REASON_UNSPECIFIED` is the +// zero value and is treated as a generic internal error by clients that do +// not recognize a (newer) reason. +enum ErrorReason { + ERROR_REASON_UNSPECIFIED = 0; + // A request was syntactically or semantically invalid (missing/blank + // required field, out-of-range value, unparseable NSQL, etc.). + ERROR_REASON_VALIDATION_FAILED = 1; + // No account exists for the referenced id. + ERROR_REASON_ACCOUNT_NOT_FOUND = 2; + // No filter rule exists for the referenced id. + ERROR_REASON_RULE_NOT_FOUND = 3; + // No stored message exists for the referenced id. + ERROR_REASON_MESSAGE_NOT_FOUND = 4; + // No stored calendar event exists for the referenced id. + ERROR_REASON_EVENT_NOT_FOUND = 5; + // No stored contact exists for the referenced id. + ERROR_REASON_CONTACT_NOT_FOUND = 6; + // The resource being created already exists. + ERROR_REASON_ALREADY_EXISTS = 7; + // A precondition is unmet: the operation requires configuration that is + // not present yet (e.g. no CalDAV/CardDAV account configured to sync). + ERROR_REASON_NOT_CONFIGURED = 8; + // A required backend (mail/calendar/contacts server) could not be reached. + ERROR_REASON_BACKEND_UNREACHABLE = 9; + // The call was not authenticated (missing/invalid bearer credentials). + ERROR_REASON_AUTH_REQUIRED = 10; + // The requested operation is not supported (no honest implementation). + ERROR_REASON_UNSUPPORTED = 11; + // An unexpected internal error the client cannot act on specifically. + ERROR_REASON_INTERNAL = 12; +} + +// ErrorInfo is the structured error payload the daemon attaches to a failed +// RPC's gRPC status-details (`grpc-status-details-bin`) trailer. It carries a +// stable `reason` discriminant, optional structured `metadata` (e.g. the id +// of the resource that was not found), and the same human-readable `message` +// that the enclosing `Status` carries. Clients decode this to branch on +// `reason` rather than parsing `message`. +message ErrorInfo { + // Stable machine-readable failure discriminant. + ErrorReason reason = 1; + // Optional structured context (e.g. {"account_id": "acct-1"}). Keys are + // stable identifiers; values are the concrete offending inputs. + map metadata = 2; + // Human-readable message, identical to the enclosing `Status` message. + string message = 3; +} + // System service exposes daemon-level introspection RPCs. // // This is the foundation of the versioned gRPC API (see diff --git a/crates/nuncio-proto/src/errors.rs b/crates/nuncio-proto/src/errors.rs new file mode 100644 index 0000000..c13f1c6 --- /dev/null +++ b/crates/nuncio-proto/src/errors.rs @@ -0,0 +1,153 @@ +//! Typed, machine-readable gRPC errors for the `nuncio.v1` contract. +//! +//! A failed RPC should carry a structured [`v1::ErrorInfo`] -- a stable +//! [`v1::ErrorReason`] discriminant, optional metadata, and a human message +//! -- in the gRPC `grpc-status-details-bin` status-details trailer, alongside +//! the canonical gRPC [`Code`] that the reason maps to. Servers build such a +//! `Status` with [`status`] / [`status_with_metadata`]; clients read the +//! reason back with [`error_reason`] / [`error_info`] and branch on it, +//! rather than string-matching the human-readable (English) message text. +//! +//! The reason-to-code mapping is centralized in [`ErrorReason::code`] so the +//! transport-level status code and the application-level reason can never +//! drift apart at a call site. + +use crate::v1::{ErrorInfo, ErrorReason}; +use bytes::Bytes; +use prost::Message; +use tonic::{Code, Status}; + +impl ErrorReason { + /// The canonical gRPC [`Code`] this reason maps to. Both the unspecified + /// zero value and an explicit internal error map to [`Code::Internal`], + /// so an older client that receives a reason it does not recognize (and + /// decodes it as `Unspecified`) still sees an internal-class failure. + #[must_use] + pub fn code(self) -> Code { + match self { + ErrorReason::Unspecified | ErrorReason::Internal => Code::Internal, + ErrorReason::ValidationFailed => Code::InvalidArgument, + ErrorReason::AccountNotFound + | ErrorReason::RuleNotFound + | ErrorReason::MessageNotFound + | ErrorReason::EventNotFound + | ErrorReason::ContactNotFound => Code::NotFound, + ErrorReason::AlreadyExists => Code::AlreadyExists, + ErrorReason::NotConfigured => Code::FailedPrecondition, + ErrorReason::BackendUnreachable => Code::Unavailable, + ErrorReason::AuthRequired => Code::Unauthenticated, + ErrorReason::Unsupported => Code::Unimplemented, + } + } +} + +/// Builds a [`Status`] for `reason` carrying `message` both as the `Status` +/// message and inside a prost-encoded [`v1::ErrorInfo`] in the +/// `grpc-status-details-bin` trailer, with the canonical code from +/// [`ErrorReason::code`]. +pub fn status(reason: ErrorReason, message: impl Into) -> Status { + build(reason, message.into(), Vec::new()) +} + +/// Like [`status`], but also attaches structured `metadata` (e.g. the id of +/// the resource that was not found) to the encoded [`v1::ErrorInfo`]. +pub fn status_with_metadata( + reason: ErrorReason, + message: impl Into, + metadata: impl IntoIterator, +) -> Status { + build(reason, message.into(), metadata.into_iter().collect()) +} + +fn build(reason: ErrorReason, message: String, metadata: Vec<(String, String)>) -> Status { + let info = ErrorInfo { + reason: reason as i32, + metadata: metadata.into_iter().collect(), + message: message.clone(), + }; + Status::with_details(reason.code(), message, Bytes::from(info.encode_to_vec())) +} + +/// Decodes the [`v1::ErrorInfo`] carried in `status`'s details trailer, if +/// one is present and well-formed. Returns `None` when the status carries no +/// details (e.g. an error not built through this module) or the details are +/// not a valid `ErrorInfo`. +#[must_use] +pub fn error_info(status: &Status) -> Option { + let details = status.details(); + if details.is_empty() { + return None; + } + ErrorInfo::decode(details).ok() +} + +/// Decodes just the [`v1::ErrorReason`] from `status`'s details trailer, +/// returning `None` when no typed [`v1::ErrorInfo`] is present. +#[must_use] +pub fn error_reason(status: &Status) -> Option { + error_info(status).map(|info| info.reason()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_reason_maps_to_its_canonical_code() { + assert_eq!(ErrorReason::Unspecified.code(), Code::Internal); + assert_eq!(ErrorReason::ValidationFailed.code(), Code::InvalidArgument); + assert_eq!(ErrorReason::AccountNotFound.code(), Code::NotFound); + assert_eq!(ErrorReason::RuleNotFound.code(), Code::NotFound); + assert_eq!(ErrorReason::MessageNotFound.code(), Code::NotFound); + assert_eq!(ErrorReason::EventNotFound.code(), Code::NotFound); + assert_eq!(ErrorReason::ContactNotFound.code(), Code::NotFound); + assert_eq!(ErrorReason::AlreadyExists.code(), Code::AlreadyExists); + assert_eq!(ErrorReason::NotConfigured.code(), Code::FailedPrecondition); + assert_eq!(ErrorReason::BackendUnreachable.code(), Code::Unavailable); + assert_eq!(ErrorReason::AuthRequired.code(), Code::Unauthenticated); + assert_eq!(ErrorReason::Unsupported.code(), Code::Unimplemented); + assert_eq!(ErrorReason::Internal.code(), Code::Internal); + } + + #[test] + fn status_carries_reason_code_and_decodable_details() { + let status = status(ErrorReason::MessageNotFound, "message 'm-1' not found"); + assert_eq!(status.code(), Code::NotFound); + assert_eq!(status.message(), "message 'm-1' not found"); + + let info = error_info(&status).expect("typed details present"); + assert_eq!(info.reason(), ErrorReason::MessageNotFound); + assert_eq!(info.message, "message 'm-1' not found"); + assert!(info.metadata.is_empty()); + + assert_eq!( + error_reason(&status), + Some(ErrorReason::MessageNotFound), + "the reason round-trips through the encoded details" + ); + } + + #[test] + fn status_with_metadata_round_trips_context() { + let status = status_with_metadata( + ErrorReason::AccountNotFound, + "account 'acct-1' not found", + [("account_id".to_string(), "acct-1".to_string())], + ); + assert_eq!(status.code(), Code::NotFound); + + let info = error_info(&status).expect("typed details present"); + assert_eq!(info.reason(), ErrorReason::AccountNotFound); + assert_eq!( + info.metadata.get("account_id").map(String::as_str), + Some("acct-1") + ); + } + + #[test] + fn plain_status_without_details_decodes_to_none() { + let status = Status::not_found("no typed details here"); + assert_eq!(error_info(&status), None); + assert_eq!(error_reason(&status), None); + } +} diff --git a/crates/nuncio-proto/src/lib.rs b/crates/nuncio-proto/src/lib.rs index 6fe756c..6709375 100644 --- a/crates/nuncio-proto/src/lib.rs +++ b/crates/nuncio-proto/src/lib.rs @@ -22,6 +22,7 @@ pub mod addr; pub mod client; +pub mod errors; pub use addr::{grpc_addr_from_env, DEFAULT_GRPC_ADDR, GRPC_ADDR_ENV_VAR}; diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index 23f0f66..1033f81 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -10,6 +10,7 @@ use nuncio_contacts::ContactsBackend; use nuncio_core::{CoreCommand, CoreEvent, EventBus}; use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions}; use nuncio_mail::{ImapEngine, MailBackend, MessageSender, SmtpTransportEngine}; +use nuncio_proto::errors; use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer}; use nuncio_proto::v1::audit_server::{Audit, AuditServer}; use nuncio_proto::v1::calendar_server::{Calendar, CalendarServer}; @@ -19,6 +20,7 @@ use nuncio_proto::v1::export_server::{Export, ExportServer}; use nuncio_proto::v1::filters_server::{Filters, FiltersServer}; use nuncio_proto::v1::mail_server::{Mail, MailServer}; use nuncio_proto::v1::system_server::{System, SystemServer}; +use nuncio_proto::v1::ErrorReason; use nuncio_proto::v1::{ export_request, AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto, AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto, @@ -549,7 +551,7 @@ impl Accounts for AccountsGrpcService { let config = map_account_config_from_proto(proto_config)?; config .validate() - .map_err(|e| Status::invalid_argument(e.to_string()))?; + .map_err(|e| errors::status(ErrorReason::ValidationFailed, e.to_string()))?; // Store the password credential in the OS keyring FIRST, keyed by // this account's `keyring_secret_key` -- it is never written to @@ -615,7 +617,7 @@ impl Accounts for AccountsGrpcService { let config = map_account_config_from_proto(proto_config)?; config .validate() - .map_err(|e| Status::invalid_argument(e.to_string()))?; + .map_err(|e| errors::status(ErrorReason::ValidationFailed, e.to_string()))?; // Updating a non-existent account is a client error, not a silent // create: reject it so a typo'd id never conjures a new account row. @@ -672,7 +674,13 @@ impl Accounts for AccountsGrpcService { .get_account(&req.id) .await .map_err(|e| Status::internal(format!("failed to look up account: {e}")))? - .ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?; + .ok_or_else(|| { + errors::status_with_metadata( + ErrorReason::AccountNotFound, + format!("account '{}' not found", req.id), + [("account_id".to_string(), req.id.clone())], + ) + })?; self.db .delete_account(&req.id) @@ -708,7 +716,13 @@ impl Accounts for AccountsGrpcService { .get_account(&req.id) .await .map_err(|e| Status::internal(format!("failed to look up account: {e}")))? - .ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?; + .ok_or_else(|| { + errors::status_with_metadata( + ErrorReason::AccountNotFound, + format!("account '{}' not found", req.id), + [("account_id".to_string(), req.id.clone())], + ) + })?; let password = self .secrets @@ -954,16 +968,19 @@ impl CalendarGrpcService { .collect(); if caldav_accounts.is_empty() { - return Err(Status::failed_precondition(if req.account_id.is_empty() { - "no CalDAV account is configured; add one with protocol CALDAV and a \ - collection_url before syncing" - .to_string() - } else { - format!( - "no CalDAV account with id '{}' is configured", - req.account_id - ) - })); + return Err(errors::status( + ErrorReason::NotConfigured, + if req.account_id.is_empty() { + "no CalDAV account is configured; add one with protocol CALDAV and a \ + collection_url before syncing" + .to_string() + } else { + format!( + "no CalDAV account with id '{}' is configured", + req.account_id + ) + }, + )); } let mut total = 0usize; @@ -1076,7 +1093,13 @@ impl Calendar for CalendarGrpcService { .db .get_calendar_event(&req.event_id) .await - .map_err(|e| Status::not_found(format!("event '{}' not found: {e}", req.event_id)))?; + .map_err(|e| { + errors::status_with_metadata( + ErrorReason::EventNotFound, + format!("event '{}' not found: {e}", req.event_id), + [("event_id".to_string(), req.event_id.clone())], + ) + })?; Ok(Response::new(GetEventResponse { event: Some(map_calendar_event_to_proto(event)), @@ -1183,7 +1206,11 @@ impl Contacts for ContactsGrpcService { } let contact = self.db.get_contact(&req.contact_id).await.map_err(|e| { - Status::not_found(format!("contact '{}' not found: {e}", req.contact_id)) + errors::status_with_metadata( + ErrorReason::ContactNotFound, + format!("contact '{}' not found: {e}", req.contact_id), + [("contact_id".to_string(), req.contact_id.clone())], + ) })?; Ok(Response::new(GetContactResponse { @@ -1358,7 +1385,11 @@ impl Mail for MailGrpcService { } let email = self.db.get_message(&req.message_id).await.map_err(|e| { - Status::not_found(format!("message '{}' not found: {e}", req.message_id)) + errors::status_with_metadata( + ErrorReason::MessageNotFound, + format!("message '{}' not found: {e}", req.message_id), + [("message_id".to_string(), req.message_id.clone())], + ) })?; Ok(Response::new(GetMessageResponse { @@ -1387,7 +1418,11 @@ impl Mail for MailGrpcService { .set_message_read(&req.message_id, req.read) .await .map_err(|e| { - Status::not_found(format!("message '{}' not found: {e}", req.message_id)) + errors::status_with_metadata( + ErrorReason::MessageNotFound, + format!("message '{}' not found: {e}", req.message_id), + [("message_id".to_string(), req.message_id.clone())], + ) })?; self.event_bus.process_command(CoreCommand::MarkRead { @@ -1652,11 +1687,19 @@ impl Filters for FiltersGrpcService { ) -> Result, Status> { let req = request.into_inner(); - let rule = NsqlParser::parse_rule(req.name, req.priority, &req.nsql) - .map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?; + let rule = NsqlParser::parse_rule(req.name, req.priority, &req.nsql).map_err(|e| { + errors::status( + ErrorReason::ValidationFailed, + format!("NSQL syntax error: {e}"), + ) + })?; - NsqlValidator::validate(&rule, &ValidationOptions::default()) - .map_err(|e| Status::invalid_argument(format!("NSQL validation error: {e}")))?; + NsqlValidator::validate(&rule, &ValidationOptions::default()).map_err(|e| { + errors::status( + ErrorReason::ValidationFailed, + format!("NSQL validation error: {e}"), + ) + })?; self.db .save_filter_rule(&rule) @@ -1748,8 +1791,12 @@ impl Filters for FiltersGrpcService { ) -> Result, Status> { let req = request.into_inner(); - let rule = NsqlParser::parse_rule("Preview Rule", 0, &req.nsql) - .map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?; + let rule = NsqlParser::parse_rule("Preview Rule", 0, &req.nsql).map_err(|e| { + errors::status( + ErrorReason::ValidationFailed, + format!("NSQL syntax error: {e}"), + ) + })?; let preview_engine = FilterEngine::new(vec![rule]) .map_err(|e| Status::internal(format!("failed to build preview engine: {e}")))?; @@ -1788,17 +1835,31 @@ impl Filters for FiltersGrpcService { .map_err(|e| Status::internal(format!("failed to list filter rules: {e}")))? .into_iter() .find(|r| r.id == req.id) - .ok_or_else(|| Status::not_found(format!("filter rule '{}' not found", req.id)))?; + .ok_or_else(|| { + errors::status_with_metadata( + ErrorReason::RuleNotFound, + format!("filter rule '{}' not found", req.id), + [("rule_id".to_string(), req.id.clone())], + ) + })?; let name = req.name.unwrap_or(existing.name); let nsql = req.nsql.unwrap_or(existing.nsql_text); let priority = req.priority.unwrap_or(existing.priority); - let mut rule = NsqlParser::parse_rule(name, priority, &nsql) - .map_err(|e| Status::invalid_argument(format!("NSQL syntax error: {e}")))?; + let mut rule = NsqlParser::parse_rule(name, priority, &nsql).map_err(|e| { + errors::status( + ErrorReason::ValidationFailed, + format!("NSQL syntax error: {e}"), + ) + })?; - NsqlValidator::validate(&rule, &ValidationOptions::default()) - .map_err(|e| Status::invalid_argument(format!("NSQL validation error: {e}")))?; + NsqlValidator::validate(&rule, &ValidationOptions::default()).map_err(|e| { + errors::status( + ErrorReason::ValidationFailed, + format!("NSQL validation error: {e}"), + ) + })?; rule.id = existing.id; diff --git a/crates/nunciod/tests/typed_errors_e2e_test.rs b/crates/nunciod/tests/typed_errors_e2e_test.rs new file mode 100644 index 0000000..436f49c --- /dev/null +++ b/crates/nunciod/tests/typed_errors_e2e_test.rs @@ -0,0 +1,171 @@ +//! Offline end-to-end test that typed `ErrorInfo` details survive the gRPC +//! wire. +//! +//! 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 every +//! engine override left at its default (no injected backends). +//! +//! Driving the authenticated `nuncio-proto` clients, it triggers several +//! known error conditions and proves a client can machine-read each failure: +//! the returned `tonic::Status` carries the correct canonical gRPC code AND a +//! prost-encoded [`nuncio_proto::v1::ErrorInfo`] in the +//! `grpc-status-details-bin` trailer whose `reason` (and metadata) decode +//! back exactly, so a client never has to string-match the English message. +//! +//! Fully offline: no real network, no real OS keyring, no live server. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use nuncio_core::EventBus; +use nuncio_filter::FilterEngine; +use nuncio_proto::errors; +use nuncio_proto::v1::{ + CalendarSyncRequest, CreateRuleRequest, ErrorReason, GetMessageRequest, RemoveAccountRequest, +}; +use nuncio_store::db::DatabaseEngine; +use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT}; +use nunciod::grpc::{ + serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides, + ContactsEngineOverrides, MailEngineOverrides, +}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tonic::Code; + +/// Boots the real daemon gRPC server on an ephemeral loopback port with every +/// override at its default. Returns the server address and minted bearer +/// token; the `TempDir` guard is returned so the caller controls its lifetime +/// for the whole test (the spawned server keeps using the db). +async fn boot_daemon() -> (std::net::SocketAddr, String, tempfile::TempDir) { + let secrets = Arc::new(SecretManager::mock()); + let dir = tempfile::tempdir().expect("create temp dir for file-backed db"); + let db_path = dir.path().join("typed_errors_e2e.db"); + let db = Arc::new( + DatabaseEngine::connect_file(&db_path, &secrets) + .await + .expect("open temp file-backed database"), + ); + + let token = hex::encode( + secrets + .get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32) + .expect("mint gRPC bearer token from mock vault"), + ); + + 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 filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")); + let server_secrets = secrets.clone(); + let server_token = token.clone(); + tokio::spawn(async move { + let _ = serve_on_listener_with_overrides( + listener, + event_bus, + db, + filter_engine, + server_secrets, + server_token, + MailEngineOverrides::default(), + CalendarEngineOverrides::default(), + ContactsEngineOverrides::default(), + AccountsEngineOverrides::default(), + ) + .await; + }); + + (addr, token, dir) +} + +#[tokio::test] +async fn typed_error_reasons_decode_from_status_details_over_the_wire() { + let (addr, token, _db_dir) = boot_daemon().await; + let addr_str = addr.to_string(); + + // ---- account-not-found: NotFound + ACCOUNT_NOT_FOUND + account_id ---- + let mut accounts = nuncio_proto::client::connect_accounts(&addr_str, &token) + .await + .expect("accounts client connects"); + let status = accounts + .remove_account(RemoveAccountRequest { + id: "no-such-account".to_string(), + }) + .await + .expect_err("removing a nonexistent account must fail"); + assert_eq!(status.code(), Code::NotFound); + let info = errors::error_info(&status).expect("typed ErrorInfo present in details trailer"); + assert_eq!(info.reason(), ErrorReason::AccountNotFound); + assert_eq!( + info.metadata.get("account_id").map(String::as_str), + Some("no-such-account"), + "the missing id is carried as machine-readable metadata" + ); + assert_eq!(info.message, status.message()); + assert_eq!( + errors::error_reason(&status), + Some(ErrorReason::AccountNotFound) + ); + + // ---- message-not-found: NotFound + MESSAGE_NOT_FOUND + message_id ---- + let mut mail = nuncio_proto::client::connect_mail(&addr_str, &token) + .await + .expect("mail client connects"); + let status = mail + .get_message(GetMessageRequest { + message_id: "no-such-message".to_string(), + }) + .await + .expect_err("fetching a nonexistent message must fail"); + assert_eq!(status.code(), Code::NotFound); + assert_eq!( + errors::error_reason(&status), + Some(ErrorReason::MessageNotFound) + ); + let info = errors::error_info(&status).expect("typed ErrorInfo present"); + assert_eq!( + info.metadata.get("message_id").map(String::as_str), + Some("no-such-message") + ); + + // ---- not-configured: FailedPrecondition + NOT_CONFIGURED ---- + let mut calendar = nuncio_proto::client::connect_calendar(&addr_str, &token) + .await + .expect("calendar client connects"); + let status = calendar + .sync(CalendarSyncRequest { + account_id: String::new(), + calendar_id: "personal".to_string(), + start_window: 0, + end_window: 0, + }) + .await + .expect_err("syncing with no CalDAV account configured must fail"); + assert_eq!(status.code(), Code::FailedPrecondition); + assert_eq!( + errors::error_reason(&status), + Some(ErrorReason::NotConfigured) + ); + + // ---- validation-failed: InvalidArgument + VALIDATION_FAILED ---- + let mut filters = nuncio_proto::client::connect_filters(&addr_str, &token) + .await + .expect("filters client connects"); + let status = filters + .create_rule(CreateRuleRequest { + name: "bad rule".to_string(), + nsql: "this is not valid nsql".to_string(), + priority: 0, + }) + .await + .expect_err("creating a rule from unparseable NSQL must fail"); + assert_eq!(status.code(), Code::InvalidArgument); + assert_eq!( + errors::error_reason(&status), + Some(ErrorReason::ValidationFailed) + ); +}