From 59ef2b60c847fb441d134ebb53a6ef3f9a66b378 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 29 Jul 2026 17:12:41 -0500 Subject: [PATCH] feat(calendar): add Calendar gRPC vertical (proto, daemon, CLI, E2E) Wire the orphaned nuncio-cal engine into nunciod as a real, authenticated gRPC vertical mirroring Mail: add service Calendar (Sync/ListEvents/GetEvent) to nuncio.proto with a regenerated descriptor golden, connect_calendar in nuncio-proto's client, calendar_sync::sync_with_backend plus CalendarEngineOverrides/CalendarGrpcService mounted behind its own BearerAuthInterceptor in nunciod::grpc, and real `cal sync`/`cal list` gRPC clients in nuncio-cli replacing the fabricated CLI output. ListEvents/GetEvent always read genuinely persisted data; production Sync returns an honest error since there is no persisted per-account CalDAV configuration yet, and only an injected MockCalendarBackend exercises a working Sync. Adds unit, daemon integration, and offline full-daemon E2E tests, including an unauthenticated-call-rejected assertion for the new service. Refs #185 --- crates/nuncio-cli/src/args.rs | 49 +- crates/nuncio-cli/src/runner.rs | 348 +++++++++-- crates/nuncio-cli/tests/cli_system_test.rs | 56 +- .../proto/nuncio/v1/descriptor.bin | Bin 7726 -> 8772 bytes .../nuncio-proto/proto/nuncio/v1/nuncio.proto | 91 +++ crates/nuncio-proto/src/client.rs | 43 ++ crates/nunciod/src/calendar_sync.rs | 147 +++++ crates/nunciod/src/grpc.rs | 543 +++++++++++++++++- crates/nunciod/src/lib.rs | 1 + crates/nunciod/tests/calendar_e2e_test.rs | 193 +++++++ crates/nunciod/tests/spine_e2e_test.rs | 5 +- 11 files changed, 1408 insertions(+), 68 deletions(-) create mode 100644 crates/nunciod/src/calendar_sync.rs create mode 100644 crates/nunciod/tests/calendar_e2e_test.rs diff --git a/crates/nuncio-cli/src/args.rs b/crates/nuncio-cli/src/args.rs index 5ddd11d..8cb0153 100644 --- a/crates/nuncio-cli/src/args.rs +++ b/crates/nuncio-cli/src/args.rs @@ -335,13 +335,56 @@ pub enum FolderSubcommand { } /// Calendar subcommands (`nuncio cal `). +/// +/// `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 `). diff --git a/crates/nuncio-cli/src/runner.rs b/crates/nuncio-cli/src/runner.rs index d9ad7f1..1339c18 100644 --- a/crates/nuncio-cli/src/runner.rs +++ b/crates/nuncio-cli/src/runner.rs @@ -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 { + 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 = + 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>>, + last_sync: Arc>>, + } + + #[tonic::async_trait] + impl CalendarService for StubCalendar { + async fn sync( + &self, + request: tonic::Request, + ) -> Result, 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, + ) -> Result, 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, + ) -> Result, 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 diff --git a/crates/nuncio-cli/tests/cli_system_test.rs b/crates/nuncio-cli/tests/cli_system_test.rs index 6246cef..a691156 100644 --- a/crates/nuncio-cli/tests/cli_system_test.rs +++ b/crates/nuncio-cli/tests/cli_system_test.rs @@ -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, + ); } diff --git a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin index ef5989bbd7c82c2b327b3e4c4bda1f8179eb3e9f..5a4b5ad575acfc32c30137c1e29cb8ad29503650 100644 GIT binary patch delta 978 zcmb_ayH3ME5X3kSdjop_35l&_~@cHiw`;=9qoB_?#ZXKtmd zQ1T?$4SY*q)9R)J3hMaCf?}NX`)+h)@BH$6eT@u6;odFXv)dnH=2f4!RUpaOM{SDhF~@46Jdg;0fnyk-7JCGRa*N_ zg9R1uzDRIPxl7mc!h}$sPvaFGQz9KdW&I`wFf9b*16YTdEao5}emD>#(w&mP z&IH7J@TgM2$HXSVkc_r>p)%<#J`YKZ5vx43=ho6&4a&zsOvP5>zZw5W-|3IW4*`w) zlD4;?fcgEzY?^w#v|}|$LcAbsTy8g1P7%eCI(q}=+Bl1+((&uyMc7hK>; +/// The authenticated `nuncio.v1.Calendar` client type returned by +/// [`connect_calendar`]. +pub type AuthenticatedCalendarClient = + CalendarClient>; + /// 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 ` 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 { + 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 diff --git a/crates/nunciod/src/calendar_sync.rs b/crates/nunciod/src/calendar_sync.rs new file mode 100644 index 0000000..29618ac --- /dev/null +++ b/crates/nunciod/src/calendar_sync.rs @@ -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 { + 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")); + } +} diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index a9a7a64..a8a2654 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -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>, +} + +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, + overrides: CalendarEngineOverrides, +} + +#[tonic::async_trait] +impl Calendar for CalendarGrpcService { + async fn sync( + &self, + request: Request, + ) -> Result, 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, + ) -> Result, 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, + ) -> Result, 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, @@ -1180,6 +1324,7 @@ pub async fn serve_on_listener_with_overrides( secrets: Arc, token: impl Into>, overrides: MailEngineOverrides, + calendar_overrides: CalendarEngineOverrides, ) -> Result<(), GrpcServeError> { let token: Arc = 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, 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, + db: Arc, + filter_engine: Arc, + secrets: Arc, + 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" + ); + } } diff --git a/crates/nunciod/src/lib.rs b/crates/nunciod/src/lib.rs index b6d74f6..11bf971 100644 --- a/crates/nunciod/src/lib.rs +++ b/crates/nunciod/src/lib.rs @@ -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; diff --git a/crates/nunciod/tests/calendar_e2e_test.rs b/crates/nunciod/tests/calendar_e2e_test.rs new file mode 100644 index 0000000..98aaac7 --- /dev/null +++ b/crates/nunciod/tests/calendar_e2e_test.rs @@ -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, + 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); +} diff --git a/crates/nunciod/tests/spine_e2e_test.rs b/crates/nunciod/tests/spine_e2e_test.rs index bfc8f48..ac7e161 100644 --- a/crates/nunciod/tests/spine_e2e_test.rs +++ b/crates/nunciod/tests/spine_e2e_test.rs @@ -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; }); -- 2.54.0