feat(calendar): add Calendar gRPC vertical (proto, daemon, CLI, E2E) #214
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user