feat(proto): typed errors (ErrorInfo/ErrorReason) via Status details #342
No files matched your search
Generated
+1
@@ -2594,6 +2594,7 @@ dependencies = [
|
||||
name = "nuncio-proto"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"protoc-bin-vendored",
|
||||
"thiserror 1.0.69",
|
||||
|
||||
@@ -13,6 +13,7 @@ workspace = true
|
||||
tonic.workspace = true
|
||||
tonic-prost.workspace = true
|
||||
prost.workspace = true
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
Binary file not shown.
@@ -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<string, string> 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
|
||||
|
||||
@@ -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<String>) -> 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<String>,
|
||||
metadata: impl IntoIterator<Item = (String, String)>,
|
||||
) -> 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<ErrorInfo> {
|
||||
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<ErrorReason> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
+90
-29
@@ -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<Response<CreateRuleResponse>, 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<Response<PreviewRuleResponse>, 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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user