feat(calendar): add Calendar gRPC vertical (proto, daemon, CLI, E2E) #214

Merged
KofTwentyTwo merged 1 commits from story/m1-calendar-api into dev 2026-07-29 22:19:16 +00:00
11 changed files with 1408 additions and 68 deletions

No files matched your search

+46 -3
View File
@@ -335,13 +335,56 @@ pub enum FolderSubcommand {
}
/// Calendar subcommands (`nuncio cal <verb>`).
///
/// `account`/`calendar` currently have no persisted CalDAV account/collection
/// concept to look up defaults from (see `nunciod::calendar_sync`'s doc
/// comment), so both are plain flags rather than resolved from configured
/// account state; `start`/`end` default to a window spanning "everything"
/// (`0` .. `i64::MAX` unix seconds) so `list`/`sync` are usable with no flags
/// at all once an account/calendar id is supplied.
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CalSubcommand {
/// List upcoming calendar events.
List,
/// List calendar events for an account within an optional time window.
List {
/// Account identifier owning the calendar.
#[arg(short, long, help = "Account identifier")]
account: String,
/// Calendar collection identifier.
#[arg(
short,
long,
default_value = "default",
help = "Calendar collection identifier"
)]
calendar: String,
/// Window start (unix seconds, inclusive).
#[arg(long, default_value_t = 0, help = "Window start (unix seconds)")]
start: i64,
/// Window end (unix seconds, inclusive).
#[arg(long, default_value_t = i64::MAX, help = "Window end (unix seconds)")]
end: i64,
},
/// Synchronize local calendar cache with remote CalDAV server.
Sync,
Sync {
/// Account identifier owning the calendar.
#[arg(short, long, help = "Account identifier")]
account: String,
/// Calendar collection identifier.
#[arg(
short,
long,
default_value = "default",
help = "Calendar collection identifier"
)]
calendar: String,
/// Window start (unix seconds, inclusive).
#[arg(long, default_value_t = 0, help = "Window start (unix seconds)")]
start: i64,
/// Window end (unix seconds, inclusive).
#[arg(long, default_value_t = i64::MAX, help = "Window end (unix seconds)")]
end: i64,
},
}
/// System subcommands (`nuncio system <verb>`).
+314 -34
View File
@@ -1,6 +1,6 @@
//! Headless engine runner executing CLI commands against core services.
use nuncio_core::{CoreCommand, EventBus};
use nuncio_core::EventBus;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nuncio_store::{DatabaseEngine, DatabaseError};
use serde_json::json;
@@ -57,6 +57,22 @@ fn message_proto_to_json(message: &nuncio_proto::v1::Message) -> serde_json::Val
})
}
/// Renders a `nuncio.v1.CalendarEvent` (as returned by the daemon's
/// `Calendar` gRPC service) into the JSON shape used by `cal list`'s
/// `--json` output.
fn calendar_event_proto_to_json(event: &nuncio_proto::v1::CalendarEvent) -> serde_json::Value {
json!({
"id": event.id,
"account_id": event.account_id,
"calendar_id": event.calendar_id,
"summary": event.summary,
"start_time": event.start_time,
"end_time": event.end_time,
"rrule": event.rrule,
"location": event.location,
})
}
/// Maps a `nuncio_core::export::ExportFormat` onto its wire-format
/// `nuncio.v1.ExportFormat` enum value, mirroring `nunciod::grpc`'s
/// server-side mapping.
@@ -348,20 +364,23 @@ impl HeadlessRunner {
FolderSubcommand::List => self.handle_folders_list(json_mode).await,
},
Commands::Cal { action } => match action {
CalSubcommand::List => {
if json_mode {
format_json(&json!({ "events": [] }))
} else {
"Calendar Events: 0 events found".to_string()
}
CalSubcommand::List {
account,
calendar,
start,
end,
} => {
self.handle_cal_list(account, calendar, *start, *end, json_mode)
.await
}
CalSubcommand::Sync => {
self.event_bus.process_command(CoreCommand::SyncAll);
if json_mode {
format_json(&json!({ "status": "calendar_sync_started" }))
} else {
"Calendar synchronization started.".to_string()
}
CalSubcommand::Sync {
account,
calendar,
start,
end,
} => {
self.handle_cal_sync(account, calendar, *start, *end, json_mode)
.await
}
},
Commands::System { action } => match action {
@@ -1585,6 +1604,115 @@ impl HeadlessRunner {
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// `nuncio.v1.Calendar` service at `self.grpc_addr`, shared by
/// [`Self::handle_cal_list`] and [`Self::handle_cal_sync`].
async fn connect_calendar_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedCalendarClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
nuncio_proto::client::connect_calendar(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// `cal list`: a real thin gRPC client of the running `nunciod` daemon's
/// `nuncio.v1.Calendar/ListEvents` API. Returns only genuinely persisted
/// events from the daemon's real store -- never a fabricated
/// "0 events found".
async fn handle_cal_list(
&self,
account: &str,
calendar: &str,
start: i64,
end: i64,
json_mode: bool,
) -> String {
let mut client = match self.connect_calendar_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.list_events(nuncio_proto::v1::ListEventsRequest {
account_id: account.to_string(),
calendar_id: calendar.to_string(),
start_window: start,
end_window: end,
})
.await
{
Ok(response) => {
let events = response.into_inner().events;
if json_mode {
let events_json: Vec<serde_json::Value> =
events.iter().map(calendar_event_proto_to_json).collect();
format_json(&json!({
"account": account,
"calendar": calendar,
"events": events_json
}))
} else {
format!("Calendar Events: {} event(s) found", events.len())
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected list_events: {status}"),
json_mode,
),
}
}
/// `cal sync`: a real thin gRPC client of the running `nunciod` daemon's
/// `nuncio.v1.Calendar/Sync` API. With no persisted per-account CalDAV
/// configuration, the daemon's production `Sync` path returns an honest
/// error -- this NEVER prints a fabricated "Calendar synchronization
/// started" the way this command used to.
async fn handle_cal_sync(
&self,
account: &str,
calendar: &str,
start: i64,
end: i64,
json_mode: bool,
) -> String {
let mut client = match self.connect_calendar_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.sync(nuncio_proto::v1::CalendarSyncRequest {
account_id: account.to_string(),
calendar_id: calendar.to_string(),
start_window: start,
end_window: end,
})
.await
{
Ok(response) => {
let synced_count = response.into_inner().synced_count;
if json_mode {
format_json(&json!({
"status": "calendar_sync_complete",
"synced_count": synced_count,
}))
} else {
format!("Calendar synchronization complete: {synced_count} event(s) synced")
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected calendar sync: {status}"),
json_mode,
),
}
}
/// Formats an error message consistently for both JSON and human-text
/// output modes.
fn render_error(message: &str, json_mode: bool) -> String {
@@ -1781,26 +1909,14 @@ mod tests {
// `SecretManager::mock()` against a live stub `Mail` gRPC server
// (`mail_rpcs_round_trip_over_grpc_to_a_stub_daemon`).
// Cal Noun Commands
let cal_list = runner
.execute_command(
&Commands::Cal {
action: CalSubcommand::List,
},
true,
)
.await;
assert!(cal_list.contains(r#""events":[]"#));
let cal_sync = runner
.execute_command(
&Commands::Cal {
action: CalSubcommand::Sync,
},
true,
)
.await;
assert!(cal_sync.contains(r#""status":"calendar_sync_started""#));
// Cal Noun Commands: `List`/`Sync` are real gRPC clients of the
// `nunciod` daemon's `Calendar` API -- exactly like the Mail Noun
// Commands 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 `Calendar` gRPC server
// (`calendar_rpcs_round_trip_over_grpc_to_a_stub_daemon`).
// System Noun Commands are exercised separately below via
// `ephemeral_with` + `SecretManager::mock()`: `system status` is a
@@ -2426,6 +2542,170 @@ mod tests {
assert_eq!(recorded_send.body_text, "Let's discuss the roadmap.");
}
/// Reference-client proof: boots a stub `nuncio.v1.Calendar` gRPC server
/// (mirroring the `Mail` stub pattern above) and drives the real
/// `HeadlessRunner`'s `cal list`/`cal sync` gRPC client paths against it.
///
/// Real persistence and the honest-error-when-no-backend-injected
/// behavior are proven by `nunciod`'s own `grpc::tests`; this test exists
/// purely to prove the CLI's connect + call + JSON-format happy path,
/// and that the exact account/calendar/window it was given reaches the
/// daemon over the wire.
#[tokio::test]
async fn calendar_rpcs_round_trip_over_grpc_to_a_stub_daemon() {
use nuncio_proto::v1::calendar_server::{Calendar as CalendarService, CalendarServer};
use nuncio_proto::v1::{
CalendarEvent as CalendarEventProto, CalendarSyncRequest, CalendarSyncResponse,
GetEventRequest, GetEventResponse, ListEventsRequest, ListEventsResponse,
};
use std::sync::Mutex;
fn stub_event() -> CalendarEventProto {
CalendarEventProto {
id: "evt-stub-1".to_string(),
account_id: "acct-stub-1".to_string(),
calendar_id: "cal-stub".to_string(),
summary: "Stub Meeting".to_string(),
start_time: 1_700_000_000,
end_time: 1_700_003_600,
rrule: None,
location: Some("Room 1".to_string()),
}
}
/// Minimal test-only stub of `nuncio.v1.Calendar`: records the last
/// `ListEventsRequest`/`CalendarSyncRequest` it received (so this
/// test can assert on exactly what the CLI sent over the wire) and
/// otherwise returns fixed responses.
#[derive(Default)]
struct StubCalendar {
last_list_events: Arc<Mutex<Option<ListEventsRequest>>>,
last_sync: Arc<Mutex<Option<CalendarSyncRequest>>>,
}
#[tonic::async_trait]
impl CalendarService for StubCalendar {
async fn sync(
&self,
request: tonic::Request<CalendarSyncRequest>,
) -> Result<tonic::Response<CalendarSyncResponse>, tonic::Status> {
let req = request.into_inner();
*self.last_sync.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(CalendarSyncResponse {
synced_count: 2,
}))
}
async fn list_events(
&self,
request: tonic::Request<ListEventsRequest>,
) -> Result<tonic::Response<ListEventsResponse>, tonic::Status> {
let req = request.into_inner();
*self
.last_list_events
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(ListEventsResponse {
events: vec![stub_event()],
}))
}
async fn get_event(
&self,
request: tonic::Request<GetEventRequest>,
) -> Result<tonic::Response<GetEventResponse>, tonic::Status> {
let req = request.into_inner();
if req.event_id == "evt-stub-1" {
Ok(tonic::Response::new(GetEventResponse {
event: Some(stub_event()),
}))
} else {
Err(tonic::Status::not_found(format!(
"event '{}' not found",
req.event_id
)))
}
}
}
let stub = StubCalendar::default();
let list_probe = stub.last_list_events.clone();
let sync_probe = stub.last_sync.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(CalendarServer::new(stub))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await;
});
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("ephemeral runner initializes");
// `cal list`: proves the CLI is a real gRPC client of
// `Calendar/ListEvents` -- the exact account/calendar/window
// supplied reaches the daemon over the wire, and the daemon's real
// returned events (NOT a fabricated "0 events found") appear in the
// CLI's output.
let cal_list = runner
.execute_command(
&Commands::Cal {
action: CalSubcommand::List {
account: "acct-stub-1".to_string(),
calendar: "cal-stub".to_string(),
start: 0,
end: i64::MAX,
},
},
true,
)
.await;
assert!(cal_list.contains("evt-stub-1"));
assert!(cal_list.contains("Stub Meeting"));
let recorded_list = list_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a list_events request");
assert_eq!(recorded_list.account_id, "acct-stub-1");
assert_eq!(recorded_list.calendar_id, "cal-stub");
assert_eq!(recorded_list.start_window, 0);
assert_eq!(recorded_list.end_window, i64::MAX);
// `cal sync`: proves the CLI is a real gRPC client of
// `Calendar/Sync` and reports the daemon's real `synced_count`,
// rather than fabricating a "calendar_sync_started" status by only
// flipping this runner's own throwaway local `EventBus`.
let cal_sync = runner
.execute_command(
&Commands::Cal {
action: CalSubcommand::Sync {
account: "acct-stub-1".to_string(),
calendar: "cal-stub".to_string(),
start: 0,
end: i64::MAX,
},
},
true,
)
.await;
assert!(cal_sync.contains(r#""status":"calendar_sync_complete""#));
assert!(cal_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, "acct-stub-1");
assert_eq!(recorded_sync.calendar_id, "cal-stub");
}
/// Reference-client proof: boots a stub `nuncio.v1.Filters` gRPC server
/// (mirroring the `Accounts`/`Mail` stub pattern above) and drives the
/// real `HeadlessRunner`'s
+41 -15
View File
@@ -42,17 +42,11 @@ async fn system_test_cli_noun_verb_execution_matrix() {
// separately below via `ephemeral_with` + `SecretManager::mock()`
// rather than through this `ephemeral()`-constructed runner.
// 5. Calendar list & sync
let out: String = runner
.execute_command(
&Commands::Cal {
action: CalSubcommand::List,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 5. Calendar list & sync are real gRPC clients of the `nunciod`
// daemon's `Calendar` API, so -- exactly like Folder/Mail above -- they
// are exercised separately below via `ephemeral_with` +
// `SecretManager::mock()` rather than through this
// `ephemeral()`-constructed runner.
}
/// `system status` is a real gRPC client of the `nunciod` daemon. With no
@@ -144,10 +138,10 @@ async fn account_add_and_list_report_honest_errors_when_daemon_unreachable() {
assert!(!add_out.contains("irrelevant-unreachable-daemon"));
}
/// `folder list`, `mail list`, `mail read`, `mail search`, and `mail mark`
/// are real gRPC clients of the `nunciod` daemon's `Mail` API. With no
/// daemon reachable, all of them must report a clear, honest error rather
/// than fabricating an empty result.
/// `folder list`, `mail list`, `mail read`, `mail search`, `mail mark`, and
/// `cal list`/`cal sync` are real gRPC clients of the `nunciod` daemon's
/// `Mail`/`Calendar` APIs. With no daemon reachable, all of them must report
/// a clear, honest error rather than fabricating an empty result.
#[tokio::test]
async fn mail_and_folder_report_honest_errors_when_daemon_unreachable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -233,4 +227,36 @@ async fn mail_and_folder_report_honest_errors_when_daemon_unreachable() {
)
.await,
);
assert_honest_error(
runner
.execute_command(
&Commands::Cal {
action: CalSubcommand::List {
account: "acct-1".to_string(),
calendar: "default".to_string(),
start: 0,
end: i64::MAX,
},
},
true,
)
.await,
);
assert_honest_error(
runner
.execute_command(
&Commands::Cal {
action: CalSubcommand::Sync {
account: "acct-1".to_string(),
calendar: "default".to_string(),
start: 0,
end: i64::MAX,
},
},
true,
)
.await,
);
}
Binary file not shown.
@@ -644,6 +644,97 @@ message ExportResponse {
uint64 bytes_written = 3;
}
// Calendar exposes the READ path over the daemon's real, persistent calendar
// event store (`DatabaseEngine::save_calendar_event`/`list_calendar_events`/
// `get_calendar_event`), plus a real `Sync` that fetches events from a
// `CalendarBackend` and persists them.
//
// This is an honest surface: there is currently no way to persist a
// per-account CalDAV collection URL/credential, so `Sync` in production (no
// injected test backend) has no real CalDAV connection to make and returns
// an error rather than a fabricated success. There are deliberately no
// create/delete RPCs here -- this service does not add real CalDAV write
// support.
//
// SECURITY: this service MUST be mounted behind the SAME
// `BearerAuthInterceptor` that guards every other service `nunciod` exposes
// -- no exceptions carved out for "just one more" unauthenticated service.
// See `nunciod::grpc::serve_on_listener`.
service Calendar {
// Sync fetches events for `calendar_id` within `[start_window, end_window]`
// from the account's calendar backend and persists them via the store.
// With no per-account CalDAV configuration available in production, this
// returns `Status::internal` describing that gap unless a test double has
// been injected -- never a fabricated `synced_count`.
rpc Sync(CalendarSyncRequest) returns (CalendarSyncResponse);
// ListEvents returns persisted events for `account_id` whose window
// overlaps `[start_window, end_window]`, ordered by start time -- always
// genuinely persisted data, never fabricated.
rpc ListEvents(ListEventsRequest) returns (ListEventsResponse);
// GetEvent returns a single persisted event by ID.
rpc GetEvent(GetEventRequest) returns (GetEventResponse);
}
// Mirrors `nuncio_core::model::CalendarEvent` field-for-field. Named
// `CalendarEvent` (matching the core model) rather than `Event` -- `Event` is
// already taken by `System`'s push-event wrapper.
message CalendarEvent {
// Event identifier.
string id = 1;
// Account identifier owning the event.
string account_id = 2;
// Calendar collection identifier.
string calendar_id = 3;
// Event title or summary.
string summary = 4;
// Unix timestamp for event start.
int64 start_time = 5;
// Unix timestamp for event end.
int64 end_time = 6;
// Recurrence rule string (RFC 5545 RRULE format).
optional string rrule = 7;
// Location text.
optional string location = 8;
}
// CalendarSyncRequest carries the calendar collection and time window to
// sync, since `CalendarBackend::fetch_events` is inherently scoped that way
// (unlike `Mail`'s account-wide `Sync`).
message CalendarSyncRequest {
string account_id = 1;
string calendar_id = 2;
int64 start_window = 3;
int64 end_window = 4;
}
// CalendarSyncResponse reports how many events were synchronized.
message CalendarSyncResponse {
uint64 synced_count = 1;
}
// ListEventsRequest.
message ListEventsRequest {
string account_id = 1;
string calendar_id = 2;
int64 start_window = 3;
int64 end_window = 4;
}
// ListEventsResponse.
message ListEventsResponse {
repeated CalendarEvent events = 1;
}
// GetEventRequest.
message GetEventRequest {
string event_id = 1;
}
// GetEventResponse.
message GetEventResponse {
CalendarEvent event = 1;
}
// Audit exposes the daemon's real WORM (Write Once, Read Many) HMAC-chained
// tamper-evident audit ledger (`nuncio_core::worm_audit`, via
// `DatabaseEngine`'s `worm_audit_records` table) over authenticated gRPC.
+43
View File
@@ -11,6 +11,7 @@
use crate::v1::accounts_client::AccountsClient;
use crate::v1::audit_client::AuditClient;
use crate::v1::calendar_client::CalendarClient;
use crate::v1::export_client::ExportClient;
use crate::v1::filters_client::FiltersClient;
use crate::v1::mail_client::MailClient;
@@ -112,6 +113,11 @@ pub type AuthenticatedExportClient =
pub type AuthenticatedAuditClient =
AuditClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// The authenticated `nuncio.v1.Calendar` client type returned by
/// [`connect_calendar`].
pub type AuthenticatedCalendarClient =
CalendarClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// Dials `addr` (a `host:port` pair, e.g. `127.0.0.1:9420`) over plain HTTP
/// (the loopback gRPC transport is never TLS-wrapped; auth is via bearer
/// token instead) and returns the connected [`Channel`], shared by every
@@ -238,6 +244,26 @@ pub async fn connect_audit(
Ok(AuditClient::with_interceptor(channel, interceptor))
}
/// Dials the `nuncio.v1.Calendar` gRPC endpoint at `addr` and returns a
/// client that injects `authorization: Bearer <token>` metadata on every
/// call.
///
/// `Calendar` is guarded by the exact same `BearerAuthInterceptor` as every
/// other service on the server side (see
/// `nunciod::grpc::serve_on_listener_with_overrides`), so this shares
/// [`BearerTokenInterceptor`] and [`dial`] with [`connect_system`] /
/// [`connect_accounts`] / [`connect_mail`] / [`connect_filters`] /
/// [`connect_export`] / [`connect_audit`] rather than hand-rolling yet
/// another auth handshake.
pub async fn connect_calendar(
addr: &str,
token: &str,
) -> Result<AuthenticatedCalendarClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let channel = dial(addr).await?;
Ok(CalendarClient::with_interceptor(channel, interceptor))
}
/// Opens the `nuncio.v1.System/Subscribe` server-streaming RPC on an already
/// authenticated client (as returned by [`connect_system`]), reusing the
/// bearer-token interceptor already attached to `client` rather than
@@ -396,6 +422,23 @@ mod tests {
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_calendar_fails_closed_on_invalid_token() {
let err = connect_calendar("127.0.0.1:0", "tok\ntoken")
.await
.expect_err("invalid token must fail before dialing");
assert!(matches!(err, ConnectError::InvalidToken(_)));
}
#[tokio::test]
async fn connect_calendar_reports_transport_error_when_daemon_unreachable() {
let err = connect_calendar("127.0.0.1:1", "abc123")
.await
.expect_err("connecting to an unreachable daemon must fail");
assert!(matches!(err, ConnectError::Transport { .. }));
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_system_reports_invalid_endpoint_for_a_malformed_address() {
// An embedded space is not a legal URI authority character, so
+147
View File
@@ -0,0 +1,147 @@
//! Real inbound calendar synchronization routine.
//!
//! Mirrors `nunciod::sync`'s shape for the calendar engine: [`sync_with_backend`]
//! fetches events from a [`CalendarBackend`] for a single calendar collection and
//! time window, then persists each event via [`DatabaseEngine::save_calendar_event`].
//!
//! # No production entry point yet
//!
//! Unlike `sync.rs`'s `run_account_sync`/`run_all_accounts_sync`, this module
//! deliberately has NO production entry point that resolves a real account's
//! CalDAV collection URL and credential and builds a real `CalDavClient` from
//! them: `nuncio_core::AccountConfig` carries no CalDAV fields today, so there
//! is nothing persisted to build one from. [`sync_with_backend`] is the only
//! function here; it is exercised directly in tests with `MockCalendarBackend`,
//! and it is the same seam a future story's production entry point will call
//! once per-account CalDAV configuration exists.
use nuncio_cal::CalendarBackend;
use nuncio_store::db::DatabaseError;
use thiserror::Error;
/// Errors that can occur while synchronizing a single calendar collection's events.
#[derive(Debug, Error)]
pub enum CalendarSyncError {
/// The calendar backend (CalDAV/mock) reported an error.
#[error("calendar backend error: {0}")]
Backend(#[from] nuncio_cal::CalendarError),
/// A database persistence error occurred while saving synced events.
#[error("database error: {0}")]
Store(#[from] DatabaseError),
}
/// Fetch every event in `calendar_id` whose window overlaps `[start_window,
/// end_window]` from `backend`, persisting each via
/// [`DatabaseEngine::save_calendar_event`]. Returns the number of events
/// processed.
///
/// Pure fetch-and-persist, unit-testable directly with a
/// [`nuncio_cal::MockCalendarBackend`] -- no event-bus or credential logic,
/// mirroring `nunciod::sync::fetch_and_persist`.
pub async fn sync_with_backend(
db: &nuncio_store::db::DatabaseEngine,
backend: &dyn CalendarBackend,
calendar_id: &str,
start_window: i64,
end_window: i64,
) -> Result<usize, CalendarSyncError> {
let events = backend
.fetch_events(calendar_id, start_window, end_window)
.await?;
let mut synced = 0usize;
for event in events {
db.save_calendar_event(&event).await?;
synced += 1;
}
Ok(synced)
}
#[cfg(test)]
mod tests {
use super::*;
use nuncio_cal::MockCalendarBackend;
use nuncio_core::model::CalendarEvent;
use nuncio_store::db::DatabaseEngine;
fn mock_event(id: &str, calendar_id: &str, start: i64, end: i64) -> CalendarEvent {
CalendarEvent {
id: id.to_string(),
account_id: "acct-cal-1".to_string(),
calendar_id: calendar_id.to_string(),
summary: format!("Event {id}"),
start_time: start,
end_time: end,
rrule: None,
location: None,
}
}
#[tokio::test]
async fn sync_with_backend_persists_mock_events() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let mock = MockCalendarBackend::new();
mock.add_event(mock_event(
"evt-1",
"cal-work",
1_700_000_000,
1_700_003_600,
));
mock.add_event(mock_event(
"evt-2",
"cal-work",
1_700_010_000,
1_700_013_600,
));
// Outside the requested window entirely -- must not be returned or persisted.
mock.add_event(mock_event(
"evt-out-of-window",
"cal-work",
1_800_000_000,
1_800_003_600,
));
let synced = sync_with_backend(&db, &mock, "cal-work", 1_699_999_000, 1_700_020_000)
.await
.expect("sync succeeds");
assert_eq!(synced, 2);
let persisted = db
.list_calendar_events("acct-cal-1", 1_699_999_000, 1_700_020_000)
.await
.expect("list persisted events");
assert_eq!(persisted.len(), 2);
let ids: Vec<&str> = persisted.iter().map(|e| e.id.as_str()).collect();
assert!(ids.contains(&"evt-1"));
assert!(ids.contains(&"evt-2"));
}
#[tokio::test]
async fn sync_with_backend_returns_zero_for_empty_backend() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let mock = MockCalendarBackend::new();
let synced = sync_with_backend(&db, &mock, "cal-empty", 0, i64::MAX)
.await
.expect("sync succeeds");
assert_eq!(synced, 0);
}
#[tokio::test]
async fn sync_with_backend_surfaces_backend_failure() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let mock = MockCalendarBackend::new();
mock.set_should_fail(true);
let err = sync_with_backend(&db, &mock, "cal-work", 0, i64::MAX)
.await
.expect_err("sync fails");
assert!(matches!(err, CalendarSyncError::Backend(_)));
assert!(err.to_string().contains("calendar backend error"));
}
}
+528 -15
View File
@@ -5,11 +5,13 @@
//!
//! This is the daemon's sole client-facing transport.
use nuncio_cal::CalendarBackend;
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
use nuncio_mail::{MailBackend, MessageSender};
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
use nuncio_proto::v1::audit_server::{Audit, AuditServer};
use nuncio_proto::v1::calendar_server::{Calendar, CalendarServer};
use nuncio_proto::v1::event::Kind;
use nuncio_proto::v1::export_server::{Export, ExportServer};
use nuncio_proto::v1::filters_server::{Filters, FiltersServer};
@@ -18,11 +20,13 @@ use nuncio_proto::v1::system_server::{System, SystemServer};
use nuncio_proto::v1::{
export_request, AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto,
AuditRecord as AuditRecordProto, BatchFilterProgress, CreateRuleRequest, CreateRuleResponse,
AuditRecord as AuditRecordProto, BatchFilterProgress, CalendarEvent as CalendarEventProto,
CalendarSyncRequest, CalendarSyncResponse, CreateRuleRequest, CreateRuleResponse,
DatabaseRecovered, DeleteRuleRequest, DeleteRuleResponse, Event, EventError,
ExportFormat as ExportFormatProto, ExportRequest, ExportResponse, FilterExecuted,
FilterRule as FilterRuleProto, Folder as FolderProto, GetMessageRequest, GetMessageResponse,
GetStatusRequest, GetStatusResponse, ListAccountsRequest, ListAccountsResponse,
FilterRule as FilterRuleProto, Folder as FolderProto, GetEventRequest, GetEventResponse,
GetMessageRequest, GetMessageResponse, GetStatusRequest, GetStatusResponse,
ListAccountsRequest, ListAccountsResponse, ListEventsRequest, ListEventsResponse,
ListFoldersRequest, ListFoldersResponse, ListMessagesRequest, ListMessagesResponse,
ListRecordsRequest, ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest,
MarkReadResponse, Message as MessageProto, MessageFlagsChanged, MessageSearchHit,
@@ -418,6 +422,141 @@ fn map_folder_to_proto(folder: nuncio_core::model::Folder) -> FolderProto {
}
}
/// Maps a `nuncio_core::model::CalendarEvent` onto its wire-format
/// `nuncio.v1.CalendarEvent` representation. Field-for-field, no
/// decryption/derivation to do here -- calendar events are never encrypted
/// at rest (see `DatabaseEngine::save_calendar_event`'s doc comment).
fn map_calendar_event_to_proto(event: nuncio_core::model::CalendarEvent) -> CalendarEventProto {
CalendarEventProto {
id: event.id,
account_id: event.account_id,
calendar_id: event.calendar_id,
summary: event.summary,
start_time: event.start_time,
end_time: event.end_time,
rrule: event.rrule,
location: event.location,
}
}
/// Test-only injection point for the daemon's calendar sync backend,
/// mirroring [`MailEngineOverrides`]'s shape.
///
/// Production (every existing caller of [`serve`] / [`serve_on_listener`])
/// never constructs a non-default instance: [`Default`] yields
/// `calendar_backend: None`, which routes `Calendar/Sync` to an honest
/// error, since there is currently no persisted per-account CalDAV
/// configuration to build a real backend from. Only a full-daemon E2E test
/// supplies `Some(..)`, standing in a [`nuncio_cal::MockCalendarBackend`]
/// for real network I/O.
#[derive(Clone, Default)]
pub struct CalendarEngineOverrides {
/// When `Some`, `Calendar/Sync` fetches from this backend instead of
/// returning an honest "no CalDAV configuration" error.
pub calendar_backend: Option<Arc<dyn CalendarBackend>>,
}
impl std::fmt::Debug for CalendarEngineOverrides {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CalendarEngineOverrides")
.field("calendar_backend", &self.calendar_backend.is_some())
.finish()
}
}
/// `nuncio.v1.Calendar` gRPC service implementation backed by the daemon's
/// live [`DatabaseEngine`] calendar-event read/write methods.
///
/// `ListEvents`/`GetEvent` always read genuinely persisted data. `Sync`
/// fetches from [`CalendarEngineOverrides::calendar_backend`] when injected;
/// otherwise it returns an honest error, since there is no persisted
/// per-account CalDAV configuration to build a real backend from yet (see
/// `nunciod::calendar_sync`'s doc comment).
struct CalendarGrpcService {
db: Arc<DatabaseEngine>,
overrides: CalendarEngineOverrides,
}
#[tonic::async_trait]
impl Calendar for CalendarGrpcService {
async fn sync(
&self,
request: Request<CalendarSyncRequest>,
) -> Result<Response<CalendarSyncResponse>, Status> {
let req = request.into_inner();
let synced = match &self.overrides.calendar_backend {
Some(backend) => crate::calendar_sync::sync_with_backend(
&self.db,
backend.as_ref(),
&req.calendar_id,
req.start_window,
req.end_window,
)
.await
.map_err(|e| Status::internal(format!("calendar sync failed: {e}")))?,
None => {
return Err(Status::internal(format!(
"no CalDAV configuration exists for account '{}'; per-account CalDAV \
configuration is not yet implemented",
req.account_id
)));
}
};
Ok(Response::new(CalendarSyncResponse {
synced_count: synced as u64,
}))
}
async fn list_events(
&self,
request: Request<ListEventsRequest>,
) -> Result<Response<ListEventsResponse>, Status> {
let req = request.into_inner();
if req.account_id.is_empty() {
return Err(Status::invalid_argument("account_id is required"));
}
// `DatabaseEngine::list_calendar_events` filters by account_id and
// time window only -- there is no per-calendar-collection filter in
// the store today, so `req.calendar_id` is not applied here. This
// never fabricates data; it genuinely returns every persisted event
// for the account in the requested window, same as `req.calendar_id`
// being absent from the query would.
let events = self
.db
.list_calendar_events(&req.account_id, req.start_window, req.end_window)
.await
.map_err(|e| Status::internal(format!("failed to list calendar events: {e}")))?
.into_iter()
.map(map_calendar_event_to_proto)
.collect();
Ok(Response::new(ListEventsResponse { events }))
}
async fn get_event(
&self,
request: Request<GetEventRequest>,
) -> Result<Response<GetEventResponse>, Status> {
let req = request.into_inner();
if req.event_id.is_empty() {
return Err(Status::invalid_argument("event_id is required"));
}
let event = self
.db
.get_calendar_event(&req.event_id)
.await
.map_err(|e| Status::not_found(format!("event '{}' not found: {e}", req.event_id)))?;
Ok(Response::new(GetEventResponse {
event: Some(map_calendar_event_to_proto(event)),
}))
}
}
/// Test-only injection point for the daemon's inbound sync and outbound
/// send engines, threaded through the daemon's runtime wiring for the
/// full-daemon offline spine E2E test.
@@ -1104,8 +1243,9 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
/// Binds a loopback TCP listener at `addr` and serves the `nuncio.v1.System`,
/// `nuncio.v1.Accounts`, `nuncio.v1.Mail`, `nuncio.v1.Filters`,
/// `nuncio.v1.Export`, and `nuncio.v1.Audit` gRPC services on it, all
/// authenticated by `token`, until the transport server errors.
/// `nuncio.v1.Export`, `nuncio.v1.Audit`, and `nuncio.v1.Calendar` gRPC
/// services on it, all authenticated by `token`, until the transport server
/// errors.
///
/// `addr` MUST be a loopback address (e.g. `127.0.0.1:PORT`); callers are
/// responsible for passing loopback-only addresses (see
@@ -1128,8 +1268,8 @@ pub async fn serve(
}
/// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, `nuncio.v1.Mail`,
/// `nuncio.v1.Filters`, `nuncio.v1.Export`, and `nuncio.v1.Audit` gRPC
/// services on an already-bound [`TcpListener`].
/// `nuncio.v1.Filters`, `nuncio.v1.Export`, `nuncio.v1.Audit`, and
/// `nuncio.v1.Calendar` gRPC services on an already-bound [`TcpListener`].
///
/// # Security
///
@@ -1161,17 +1301,21 @@ pub async fn serve_on_listener(
secrets,
token,
MailEngineOverrides::default(),
CalendarEngineOverrides::default(),
)
.await
}
/// Identical to [`serve_on_listener`], except the `nuncio.v1.Mail` service's
/// inbound sync and outbound send engines can be overridden.
/// [`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.
/// inbound sync and outbound send engines, and the `nuncio.v1.Calendar`
/// service's sync backend, can be overridden.
/// [`serve_on_listener`] is simply this function called with
/// `MailEngineOverrides::default()` / `CalendarEngineOverrides::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.
#[allow(clippy::too_many_arguments)]
pub async fn serve_on_listener_with_overrides(
listener: TcpListener,
event_bus: Arc<EventBus>,
@@ -1180,6 +1324,7 @@ pub async fn serve_on_listener_with_overrides(
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
overrides: MailEngineOverrides,
calendar_overrides: CalendarEngineOverrides,
) -> Result<(), GrpcServeError> {
let token: Arc<str> = token.into();
@@ -1228,10 +1373,20 @@ pub async fn serve_on_listener_with_overrides(
// Audit: mounted behind its own `BearerAuthInterceptor`, exactly like
// every other service above -- see the hard invariant documented on
// this function's doc comment.
let audit_service = AuditGrpcService { db };
let audit_interceptor = BearerAuthInterceptor::new(token);
let audit_service = AuditGrpcService { db: db.clone() };
let audit_interceptor = BearerAuthInterceptor::new(token.clone());
let audit_svc = AuditServer::with_interceptor(audit_service, audit_interceptor);
// Calendar: mounted behind its own `BearerAuthInterceptor`, exactly like
// every other service above -- see the hard invariant documented on
// this function's doc comment.
let calendar_service = CalendarGrpcService {
db,
overrides: calendar_overrides,
};
let calendar_interceptor = BearerAuthInterceptor::new(token);
let calendar_svc = CalendarServer::with_interceptor(calendar_service, calendar_interceptor);
Server::builder()
.add_service(system_svc)
.add_service(accounts_svc)
@@ -1239,6 +1394,7 @@ pub async fn serve_on_listener_with_overrides(
.add_service(filters_svc)
.add_service(export_svc)
.add_service(audit_svc)
.add_service(calendar_svc)
.serve_with_incoming(TcpListenerStream::new(listener))
.await
.map_err(GrpcServeError::Transport)
@@ -1320,6 +1476,32 @@ mod tests {
secrets: Arc<SecretManager>,
token: &str,
overrides: MailEngineOverrides,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
spawn_test_server_with_all_overrides(
event_bus,
db,
filter_engine,
secrets,
token,
overrides,
CalendarEngineOverrides::default(),
)
.await
}
/// Spawns a test server on an ephemeral loopback port backed by the
/// given `db`/`filter_engine`/`secrets`/`overrides`/`calendar_overrides`:
/// the helper every test that injects a
/// [`CalendarEngineOverrides::calendar_backend`] uses to prove `Sync`
/// drives an injected test double over the real authenticated gRPC API.
async fn spawn_test_server_with_all_overrides(
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
secrets: Arc<SecretManager>,
token: &str,
overrides: MailEngineOverrides,
calendar_overrides: CalendarEngineOverrides,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -1335,6 +1517,7 @@ mod tests {
secrets,
token,
overrides,
calendar_overrides,
)
.await;
});
@@ -2022,6 +2205,52 @@ mod tests {
assert_eq!(err.code(), Code::Unauthenticated);
}
#[tokio::test]
async fn calendar_rpcs_reject_missing_bearer_token() {
// Confirms `Calendar` is mounted behind its own
// `BearerAuthInterceptor` exactly like every other service (no
// un-intercepted service).
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::{CalendarSyncRequest, GetEventRequest, ListEventsRequest};
let event_bus = Arc::new(EventBus::new());
let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.list_events(ListEventsRequest {
account_id: "acct-1".to_string(),
calendar_id: "cal-1".to_string(),
start_window: 0,
end_window: i64::MAX,
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.get_event(GetEventRequest {
event_id: "evt-1".to_string(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.sync(CalendarSyncRequest {
account_id: "acct-1".to_string(),
calendar_id: "cal-1".to_string(),
start_window: 0,
end_window: i64::MAX,
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
#[tokio::test]
async fn get_message_reports_not_found_for_unknown_message_id() {
let event_bus = Arc::new(EventBus::new());
@@ -3395,4 +3624,288 @@ mod tests {
assert_eq!(response.record_count, 1);
assert_eq!(response.first_broken_seq, Some(1));
}
// ---- Calendar ----
fn sample_calendar_event(
id: &str,
account_id: &str,
calendar_id: &str,
start: i64,
end: i64,
) -> nuncio_core::model::CalendarEvent {
nuncio_core::model::CalendarEvent {
id: id.to_string(),
account_id: account_id.to_string(),
calendar_id: calendar_id.to_string(),
summary: format!("Summary for {id}"),
start_time: start,
end_time: end,
rrule: None,
location: None,
}
}
/// `ListEvents` reads only genuinely persisted events via
/// `DatabaseEngine::list_calendar_events` -- never fabricated data.
#[tokio::test]
async fn list_events_returns_only_persisted_events_in_window() {
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::ListEventsRequest;
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
db.save_calendar_event(&sample_calendar_event(
"evt-in-window",
"acct-cal-1",
"cal-work",
1_700_000_000,
1_700_003_600,
))
.await
.expect("save event in window");
db.save_calendar_event(&sample_calendar_event(
"evt-out-of-window",
"acct-cal-1",
"cal-work",
1_800_000_000,
1_800_003_600,
))
.await
.expect("save event out of window");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.list_events(authed_bearer_request(ListEventsRequest {
account_id: "acct-cal-1".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
}))
.await
.expect("list_events succeeds")
.into_inner();
assert_eq!(response.events.len(), 1);
assert_eq!(response.events[0].id, "evt-in-window");
}
#[tokio::test]
async fn list_events_rejects_empty_account_id() {
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::ListEventsRequest;
let event_bus = Arc::new(EventBus::new());
let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.list_events(authed_bearer_request(ListEventsRequest {
account_id: String::new(),
calendar_id: "cal-work".to_string(),
start_window: 0,
end_window: i64::MAX,
}))
.await
.expect_err("empty account_id must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
#[tokio::test]
async fn get_event_returns_persisted_event() {
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::GetEventRequest;
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
db.save_calendar_event(&sample_calendar_event(
"evt-get-1",
"acct-cal-1",
"cal-work",
1_700_000_000,
1_700_003_600,
))
.await
.expect("save event");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.get_event(authed_bearer_request(GetEventRequest {
event_id: "evt-get-1".to_string(),
}))
.await
.expect("get_event succeeds")
.into_inner();
let event = response.event.expect("event present in response");
assert_eq!(event.id, "evt-get-1");
assert_eq!(event.summary, "Summary for evt-get-1");
}
#[tokio::test]
async fn get_event_reports_not_found_for_unknown_event_id() {
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::GetEventRequest;
let event_bus = Arc::new(EventBus::new());
let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.get_event(authed_bearer_request(GetEventRequest {
event_id: "does-not-exist".to_string(),
}))
.await
.expect_err("unknown event id must be rejected");
assert_eq!(err.code(), Code::NotFound);
}
/// With no `CalendarEngineOverrides::calendar_backend` injected, `Sync`
/// returns an honest error -- there is no persisted per-account CalDAV
/// configuration to build a real backend from -- never a fabricated
/// `synced_count`.
#[tokio::test]
async fn calendar_sync_without_override_reports_honest_error() {
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::CalendarSyncRequest;
let event_bus = Arc::new(EventBus::new());
let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.sync(authed_bearer_request(CalendarSyncRequest {
account_id: "acct-cal-no-config".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 0,
end_window: i64::MAX,
}))
.await
.expect_err("sync with no injected backend and no CalDAV config must fail");
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("acct-cal-no-config"));
assert!(err.message().contains("no CalDAV configuration"));
}
/// With a [`CalendarEngineOverrides::calendar_backend`] injected, `Sync`
/// genuinely fetches from it (via `calendar_sync::sync_with_backend`)
/// and awaits full completion before returning, so the synced events are
/// immediately visible to `ListEvents`/`GetEvent` -- exactly the
/// mechanism a full-daemon offline E2E test uses.
#[tokio::test]
async fn calendar_sync_with_injected_backend_persists_and_is_immediately_visible() {
use nuncio_proto::v1::calendar_client::CalendarClient;
use nuncio_proto::v1::{CalendarSyncRequest, GetEventRequest, ListEventsRequest};
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_cal::MockCalendarBackend::new();
mock_backend.add_event(sample_calendar_event(
"evt-sync-injected-1",
"acct-cal-injected",
"cal-work",
1_700_000_000,
1_700_003_600,
));
let calendar_overrides = CalendarEngineOverrides {
calendar_backend: Some(Arc::new(mock_backend)),
};
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with_all_overrides(
Arc::new(EventBus::new()),
db,
filter_engine,
secrets,
"correct-token",
MailEngineOverrides::default(),
calendar_overrides,
)
.await;
let mut client = CalendarClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.sync(authed_bearer_request(CalendarSyncRequest {
account_id: "acct-cal-injected".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
}))
.await
.expect("sync succeeds")
.into_inner();
assert_eq!(response.synced_count, 1);
// Immediately visible, no fixed sleep.
let list_response = client
.list_events(authed_bearer_request(ListEventsRequest {
account_id: "acct-cal-injected".to_string(),
calendar_id: "cal-work".to_string(),
start_window: 1_699_999_000,
end_window: 1_700_004_000,
}))
.await
.expect("list_events succeeds")
.into_inner();
assert_eq!(list_response.events.len(), 1);
assert_eq!(list_response.events[0].id, "evt-sync-injected-1");
let get_response = client
.get_event(authed_bearer_request(GetEventRequest {
event_id: "evt-sync-injected-1".to_string(),
}))
.await
.expect("get_event succeeds")
.into_inner();
assert_eq!(
get_response
.event
.expect("event present in response")
.summary,
"Summary for evt-sync-injected-1"
);
}
}
+1
View File
@@ -2,6 +2,7 @@
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
pub mod calendar_sync;
pub mod grpc;
pub mod orchestrator;
pub mod send;
+193
View File
@@ -0,0 +1,193 @@
//! Full-daemon offline end-to-end test for the calendar vertical.
//!
//! 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_cal::MockCalendarBackend`] standing in for real network
//! I/O (via the [`nunciod::grpc::CalendarEngineOverrides`] injection seam).
//!
//! Driving ONLY the authenticated `nuncio-proto` gRPC client
//! (`connect_calendar`) with the shared bearer token -- never reaching into
//! daemon internals -- this proves:
//! a. `Calendar/Sync` fetches from the injected mock backend and persists
//! the result, awaiting full completion before returning, so the
//! synced events are immediately visible to `ListEvents`/`GetEvent`.
//! b. An unauthenticated call to `Calendar` is rejected with
//! `Code::Unauthenticated`, proving it is mounted behind its own
//! `BearerAuthInterceptor` exactly like every other service.
//!
//! Deterministic and fully offline: no fixed sleeps, no real network, no
//! real OS keyring.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_cal::MockCalendarBackend;
use nuncio_core::model::CalendarEvent;
use nuncio_core::EventBus;
use nuncio_filter::FilterEngine;
use nuncio_proto::v1::{CalendarSyncRequest, GetEventRequest, ListEventsRequest};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, CalendarEngineOverrides, MailEngineOverrides,
};
use std::sync::Arc;
use tokio::net::TcpListener;
use tonic::Code;
const ACCOUNT_ID: &str = "acct-cal-e2e-1";
const CALENDAR_ID: &str = "cal-e2e-work";
fn mock_event(id: &str, start: i64, end: i64) -> CalendarEvent {
CalendarEvent {
id: id.to_string(),
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
summary: format!("E2E Event {id}"),
start_time: start,
end_time: end,
rrule: None,
location: Some("Conference Room".to_string()),
}
}
/// Boots the real daemon gRPC server, injecting `calendar_overrides`, on an
/// ephemeral loopback port backed by a real temp file-backed
/// [`DatabaseEngine`]. Returns the server address and the minted bearer
/// token; the `TempDir` guard is returned too so the caller controls its
/// lifetime for the whole test.
async fn boot_daemon(
secrets: Arc<SecretManager>,
calendar_overrides: CalendarEngineOverrides,
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
let db_path = dir.path().join("calendar_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();
let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener,
event_bus,
db,
server_filter_engine,
server_secrets,
server_token,
MailEngineOverrides::default(),
calendar_overrides,
)
.await;
});
(addr, token, dir)
}
/// The full offline, deterministic end-to-end proof of the calendar vertical:
/// `Sync` against an injected mock backend, then `ListEvents`/`GetEvent`
/// immediately reflect the persisted result -- ALL over the authenticated
/// `nuncio.v1.Calendar` gRPC API against a live in-process daemon, with
/// protocol I/O fully mocked.
#[tokio::test]
async fn calendar_sync_then_list_and_get_round_trip_over_authenticated_grpc() {
let secrets = Arc::new(SecretManager::mock());
let mock_backend = MockCalendarBackend::new();
mock_backend.add_event(mock_event("evt-e2e-1", 1_700_000_000, 1_700_003_600));
mock_backend.add_event(mock_event("evt-e2e-2", 1_700_010_000, 1_700_013_600));
// Outside the sync window this test requests -- must not be synced.
mock_backend.add_event(mock_event(
"evt-e2e-out-of-window",
1_900_000_000,
1_900_003_600,
));
let calendar_overrides = CalendarEngineOverrides {
calendar_backend: Some(Arc::new(mock_backend)),
};
let (addr, token, _db_dir) = boot_daemon(secrets, calendar_overrides).await;
let addr_str = addr.to_string();
let mut calendar_client = nuncio_proto::client::connect_calendar(&addr_str, &token)
.await
.expect("calendar client connects");
// ---- (a) Sync fetches from the injected mock backend and persists ----
let sync_response = calendar_client
.sync(CalendarSyncRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 1_699_999_000,
end_window: 1_700_020_000,
})
.await
.expect("sync succeeds")
.into_inner();
assert_eq!(sync_response.synced_count, 2);
// Immediately visible, no fixed sleep: `Sync` only returns once the
// fetch-and-persist work has fully completed.
let list_response = calendar_client
.list_events(ListEventsRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 1_699_999_000,
end_window: 1_700_020_000,
})
.await
.expect("list_events succeeds")
.into_inner();
assert_eq!(list_response.events.len(), 2);
let synced_ids: Vec<&str> = list_response.events.iter().map(|e| e.id.as_str()).collect();
assert!(synced_ids.contains(&"evt-e2e-1"));
assert!(synced_ids.contains(&"evt-e2e-2"));
assert!(!synced_ids.contains(&"evt-e2e-out-of-window"));
let get_response = calendar_client
.get_event(GetEventRequest {
event_id: "evt-e2e-1".to_string(),
})
.await
.expect("get_event succeeds")
.into_inner();
let event = get_response.event.expect("event present in response");
assert_eq!(event.summary, "E2E Event evt-e2e-1");
assert_eq!(event.location.as_deref(), Some("Conference Room"));
// ---- (b) An unauthenticated call is rejected ----
// Uses the raw generated client (bypassing `connect_calendar`'s
// bearer-token interceptor) to prove `Calendar` is mounted behind its
// own `BearerAuthInterceptor`, exactly like every other service.
let mut unauthenticated_client =
nuncio_proto::v1::calendar_client::CalendarClient::connect(format!("http://{addr_str}"))
.await
.expect("client connects at the transport level without any auth");
let err = unauthenticated_client
.list_events(ListEventsRequest {
account_id: ACCOUNT_ID.to_string(),
calendar_id: CALENDAR_ID.to_string(),
start_window: 0,
end_window: i64::MAX,
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
+4 -1
View File
@@ -42,7 +42,9 @@ use nuncio_proto::v1::{
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{serve_on_listener_with_overrides, MailEngineOverrides};
use nunciod::grpc::{
serve_on_listener_with_overrides, CalendarEngineOverrides, MailEngineOverrides,
};
use std::sync::Arc;
use tokio::net::TcpListener;
@@ -125,6 +127,7 @@ async fn boot_daemon(
server_secrets,
server_token,
overrides,
CalendarEngineOverrides::default(),
)
.await;
});