Promote dev -> main: M1 Calendar + M2 Contacts verticals #220
No files matched your search
@@ -35,3 +35,4 @@ excalidraw.log
|
||||
_reference/**/gen/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
trivy_err*.log
|
||||
@@ -86,7 +86,7 @@ cargo run -p nuncio-cli -- system status # drive it via the CLI (daemon
|
||||
> **Gate status:** the workspace is green under the pinned toolchain
|
||||
> (`rust-toolchain.toml` = 1.97.1), so the local pre-commit hook runs the exact
|
||||
> `fmt`/`clippy --all-targets`/tests that CI does — "green locally" is CI-equivalent.
|
||||
> CI itself is paused until the GitHub Actions minutes reset (releases are tag-only).
|
||||
> CI runs `fmt`/`clippy --all-targets`/tests on every PR and they are required to merge to `main`/`rc`; releases are tag-only.
|
||||
> Coverage is informational, not a 100% gate.
|
||||
|
||||
## RustRover / IDE
|
||||
|
||||
Generated
+16
-4
@@ -1819,13 +1819,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "0.15.8"
|
||||
version = "0.17.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85aad69a5625006d09c694c0cd811f3655363444e692b2a9ce410c712ec1ff96"
|
||||
checksum = "8fda1b791865374063fa57e84adfe3823c2d5cd5968d721533be4556a24cffd8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"iso8601",
|
||||
"nom 7.1.3",
|
||||
"nom 8.0.0",
|
||||
"nom-language",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -2332,6 +2333,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom-language"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"
|
||||
dependencies = [
|
||||
"nom 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2468,7 +2478,6 @@ dependencies = [
|
||||
"clap",
|
||||
"hex",
|
||||
"nuncio-cal",
|
||||
"nuncio-contacts",
|
||||
"nuncio-core",
|
||||
"nuncio-filter",
|
||||
"nuncio-mail",
|
||||
@@ -2503,6 +2512,7 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2601,6 +2611,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"keyring",
|
||||
"nuncio-contacts",
|
||||
"nuncio-core",
|
||||
"nuncio-filter",
|
||||
"serde",
|
||||
@@ -2624,6 +2635,7 @@ dependencies = [
|
||||
"hex",
|
||||
"nuncio-cal",
|
||||
"nuncio-cli",
|
||||
"nuncio-contacts",
|
||||
"nuncio-core",
|
||||
"nuncio-filter",
|
||||
"nuncio-mail",
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ rustls-pki-types = "1.7"
|
||||
tokio-stream = "0.1"
|
||||
futures = "0.3"
|
||||
wiremock = "0.6"
|
||||
icalendar = "0.15"
|
||||
icalendar = "0.17"
|
||||
chrono-tz = "0.10"
|
||||
rrule = "0.10"
|
||||
chrono = "0.4"
|
||||
|
||||
@@ -47,9 +47,15 @@ impl RecurrenceEngine {
|
||||
|
||||
let duration = event.end_time - event.start_time;
|
||||
|
||||
// `RRuleSet`'s iterator yields occurrences in strictly increasing date order, so once
|
||||
// an occurrence passes `window_end_dt` every later one will too -- `take_while` lets
|
||||
// us stop walking the (possibly unbounded, no-UNTIL/COUNT) series there instead of
|
||||
// scanning forever for a match that will never come once the series has moved past
|
||||
// the window. `skip_while` similarly avoids materializing pre-window occurrences.
|
||||
let results = rrule_set
|
||||
.into_iter()
|
||||
.filter(|dt| *dt >= window_start_dt && *dt <= window_end_dt)
|
||||
.skip_while(|dt| *dt < window_start_dt)
|
||||
.take_while(|dt| *dt <= window_end_dt)
|
||||
.take(Self::MAX_EXPANSION_COUNT as usize);
|
||||
|
||||
let mut occurrences = Vec::new();
|
||||
@@ -62,12 +68,11 @@ impl RecurrenceEngine {
|
||||
occurrences.push(instance);
|
||||
}
|
||||
|
||||
if occurrences.is_empty() {
|
||||
// Return original if no occurrences fall strictly inside window
|
||||
Ok(vec![event.clone()])
|
||||
} else {
|
||||
Ok(occurrences)
|
||||
}
|
||||
// No fallback to the raw master here: an empty result genuinely means no occurrence
|
||||
// of this recurring event falls inside the requested window (e.g. the window is past
|
||||
// an `UNTIL`/`COUNT` bound), and returning the master's own out-of-window timestamps
|
||||
// instead would fabricate a result the caller never asked for.
|
||||
Ok(occurrences)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +115,37 @@ mod tests {
|
||||
assert!(results[0].id.contains("evt-weekly_occ_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurring_event_with_no_occurrences_in_window_returns_empty() {
|
||||
let event = sample_recurring_event();
|
||||
// Window is entirely before the series' own DTSTART -- no occurrence can fall
|
||||
// inside it, so this must return no events at all, not the out-of-window master.
|
||||
let results = RecurrenceEngine::expand_occurrences(&event, 1_600_000_000, 1_600_003_600)
|
||||
.expect("expansion succeeds");
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn until_bound_stops_occurrences_past_the_bound() {
|
||||
let mut event = sample_recurring_event();
|
||||
// Weekly series ends after its third occurrence (2024-01-15).
|
||||
event.rrule = Some("FREQ=WEEKLY;INTERVAL=1;UNTIL=20240115T000000Z".to_string());
|
||||
|
||||
// Querying a window entirely after the UNTIL bound must produce zero occurrences.
|
||||
let past_bound = RecurrenceEngine::expand_occurrences(&event, 1_707_350_400, 1_709_251_200)
|
||||
.expect("expansion succeeds");
|
||||
assert!(past_bound.is_empty());
|
||||
|
||||
// Querying a window spanning the bound only returns occurrences up to and
|
||||
// including it, never past it.
|
||||
let spanning_bound = RecurrenceEngine::expand_occurrences(&event, 1704067200, 1706745600)
|
||||
.expect("expansion succeeds");
|
||||
assert_eq!(spanning_bound.len(), 3);
|
||||
for occurrence in &spanning_bound {
|
||||
assert!(occurrence.start_time <= 1_705_276_800); // 2024-01-15T00:00:00Z
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_rrule_returns_parse_error() {
|
||||
let mut event = sample_recurring_event();
|
||||
|
||||
@@ -15,7 +15,6 @@ nuncio-mail = { path = "../nuncio-mail" }
|
||||
nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
nuncio-proto = { path = "../nuncio-proto" }
|
||||
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -135,19 +135,38 @@ pub enum Commands {
|
||||
}
|
||||
|
||||
/// Contact subcommands (`nuncio contact <verb>`).
|
||||
///
|
||||
/// Every verb is scoped to an `--account`, mirroring `CalSubcommand`: the
|
||||
/// daemon's real contact store (`DatabaseEngine::list_contacts`/
|
||||
/// `get_contact`/`save_contact`) is itself account-scoped, so there is no
|
||||
/// honest way to list/search/add a contact without naming which account's
|
||||
/// address book it belongs to.
|
||||
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ContactSubcommand {
|
||||
/// List contacts from local address book.
|
||||
List,
|
||||
/// List contacts in an account's address book.
|
||||
List {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
},
|
||||
/// Search contacts by name, email, or organization.
|
||||
///
|
||||
/// There is no server-side search RPC in the `Contacts` gRPC surface;
|
||||
/// this filters the account's full `ListContacts` result client-side.
|
||||
Search {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
/// Search query string.
|
||||
#[arg(short, long, help = "Search query string")]
|
||||
query: String,
|
||||
},
|
||||
/// Add a new contact entry to address book.
|
||||
/// Add a new contact entry to an account's address book.
|
||||
Add {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
/// Contact display name.
|
||||
#[arg(short, long, help = "Display name")]
|
||||
name: String,
|
||||
@@ -158,6 +177,13 @@ pub enum ContactSubcommand {
|
||||
#[arg(short, long, help = "Organization")]
|
||||
org: Option<String>,
|
||||
},
|
||||
/// Synchronize an account's local contact cache with its remote CardDAV
|
||||
/// server.
|
||||
Sync {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Software update subcommands (`nuncio update <verb>`).
|
||||
@@ -335,13 +361,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>`).
|
||||
|
||||
+794
-93
@@ -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,66 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a `nuncio.v1.Contact` (as returned by the daemon's `Contacts`
|
||||
/// gRPC service) into the JSON shape used by `contact list`/`search`/`add`'s
|
||||
/// `--json` output.
|
||||
fn contact_proto_to_json(contact: &nuncio_proto::v1::Contact) -> serde_json::Value {
|
||||
json!({
|
||||
"id": contact.id,
|
||||
"account_id": contact.account_id,
|
||||
"display_name": contact.display_name,
|
||||
"given_name": contact.given_name,
|
||||
"family_name": contact.family_name,
|
||||
"organization": contact.organization,
|
||||
"job_title": contact.job_title,
|
||||
"emails": contact.emails.iter().map(|e| json!({
|
||||
"email": e.email,
|
||||
"label": e.label,
|
||||
"is_primary": e.is_primary,
|
||||
})).collect::<Vec<_>>(),
|
||||
"phones": contact.phones.iter().map(|p| json!({
|
||||
"phone": p.phone,
|
||||
"label": p.label,
|
||||
"is_primary": p.is_primary,
|
||||
})).collect::<Vec<_>>(),
|
||||
"is_favorite": contact.is_favorite,
|
||||
"interaction_count": contact.interaction_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns `true` if `contact`'s display name, organization, or any email
|
||||
/// address contains `query` (case-insensitive). Backs `contact search`'s
|
||||
/// client-side filtering over the full `ListContacts` result, since the
|
||||
/// `Contacts` gRPC surface has no server-side search RPC.
|
||||
fn contact_matches_query(contact: &nuncio_proto::v1::Contact, query: &str) -> bool {
|
||||
let query = query.to_lowercase();
|
||||
contact.display_name.to_lowercase().contains(&query)
|
||||
|| contact
|
||||
.organization
|
||||
.as_deref()
|
||||
.is_some_and(|org| org.to_lowercase().contains(&query))
|
||||
|| contact
|
||||
.emails
|
||||
.iter()
|
||||
.any(|e| e.email.to_lowercase().contains(&query))
|
||||
}
|
||||
|
||||
/// Maps a `nuncio_core::export::ExportFormat` onto its wire-format
|
||||
/// `nuncio.v1.ExportFormat` enum value, mirroring `nunciod::grpc`'s
|
||||
/// server-side mapping.
|
||||
@@ -348,20 +408,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 {
|
||||
@@ -374,67 +437,23 @@ impl HeadlessRunner {
|
||||
},
|
||||
},
|
||||
Commands::Contact { action } => match action {
|
||||
ContactSubcommand::List => {
|
||||
let contacts_db = match nuncio_contacts::ContactsDatabase::in_memory().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return if json_mode {
|
||||
format_json_error(&e.to_string())
|
||||
} else {
|
||||
format!("Database Error: {e}")
|
||||
};
|
||||
}
|
||||
};
|
||||
let contacts = contacts_db.list_contacts().await.unwrap_or_default();
|
||||
if json_mode {
|
||||
format_json(&json!({ "contacts": contacts }))
|
||||
} else {
|
||||
format!(
|
||||
"Contacts: {} contacts found in address book.",
|
||||
contacts.len()
|
||||
)
|
||||
}
|
||||
ContactSubcommand::List { account } => {
|
||||
self.handle_contact_list(account, json_mode).await
|
||||
}
|
||||
ContactSubcommand::Search { query } => {
|
||||
let contacts_db = match nuncio_contacts::ContactsDatabase::in_memory().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return if json_mode {
|
||||
format_json_error(&e.to_string())
|
||||
} else {
|
||||
format!("Database Error: {e}")
|
||||
};
|
||||
}
|
||||
};
|
||||
let contacts = contacts_db.search_contacts(query).await.unwrap_or_default();
|
||||
if json_mode {
|
||||
format_json(&json!({ "query": query, "contacts": contacts }))
|
||||
} else {
|
||||
format!("Search ('{}'): {} contacts matched.", query, contacts.len())
|
||||
}
|
||||
ContactSubcommand::Search { account, query } => {
|
||||
self.handle_contact_search(account, query, json_mode).await
|
||||
}
|
||||
ContactSubcommand::Add { name, email, org } => {
|
||||
let contacts_db = match nuncio_contacts::ContactsDatabase::in_memory().await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
return if json_mode {
|
||||
format_json_error(&e.to_string())
|
||||
} else {
|
||||
format!("Database Error: {e}")
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut contact = nuncio_contacts::Contact::new(name, email);
|
||||
contact.organization = org.clone();
|
||||
let _ = contacts_db.save_contact(&contact).await;
|
||||
if json_mode {
|
||||
format_json(&json!({ "status": "contact_created", "contact": contact }))
|
||||
} else {
|
||||
format!(
|
||||
"✓ Contact '{}' saved to address book.",
|
||||
contact.display_name
|
||||
)
|
||||
}
|
||||
ContactSubcommand::Add {
|
||||
account,
|
||||
name,
|
||||
email,
|
||||
org,
|
||||
} => {
|
||||
self.handle_contact_add(account, name, email, org.as_deref(), json_mode)
|
||||
.await
|
||||
}
|
||||
ContactSubcommand::Sync { account } => {
|
||||
self.handle_contact_sync(account, json_mode).await
|
||||
}
|
||||
},
|
||||
Commands::Filter { action } => match action {
|
||||
@@ -1585,6 +1604,306 @@ 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// `nuncio.v1.Contacts` service at `self.grpc_addr`, shared by every
|
||||
/// `contact` handler above.
|
||||
async fn connect_contacts_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedContactsClient, 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_contacts(&self.grpc_addr, &token)
|
||||
.await
|
||||
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
|
||||
}
|
||||
|
||||
/// `contact list`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Contacts/ListContacts` API. Returns only
|
||||
/// genuinely persisted contacts from the daemon's real store -- never a
|
||||
/// fabricated "0 contacts found".
|
||||
async fn handle_contact_list(&self, account: &str, json_mode: bool) -> String {
|
||||
let mut client = match self.connect_contacts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.list_contacts(nuncio_proto::v1::ListContactsRequest {
|
||||
account_id: account.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let contacts = response.into_inner().contacts;
|
||||
if json_mode {
|
||||
let contacts_json: Vec<serde_json::Value> =
|
||||
contacts.iter().map(contact_proto_to_json).collect();
|
||||
format_json(&json!({ "account": account, "contacts": contacts_json }))
|
||||
} else {
|
||||
format!(
|
||||
"Contacts: {} contact(s) found in address book.",
|
||||
contacts.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected list_contacts: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `contact search`: fetches `account`'s full `ListContacts` result over
|
||||
/// the real gRPC API, then filters it client-side by `query` against
|
||||
/// display name, organization, and email address (case-insensitive) --
|
||||
/// there is no server-side search RPC on the `Contacts` surface.
|
||||
async fn handle_contact_search(&self, account: &str, query: &str, json_mode: bool) -> String {
|
||||
let mut client = match self.connect_contacts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.list_contacts(nuncio_proto::v1::ListContactsRequest {
|
||||
account_id: account.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let matches: Vec<_> = response
|
||||
.into_inner()
|
||||
.contacts
|
||||
.into_iter()
|
||||
.filter(|c| contact_matches_query(c, query))
|
||||
.collect();
|
||||
if json_mode {
|
||||
let contacts_json: Vec<serde_json::Value> =
|
||||
matches.iter().map(contact_proto_to_json).collect();
|
||||
format_json(&json!({
|
||||
"account": account,
|
||||
"query": query,
|
||||
"contacts": contacts_json
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"Search ('{}'): {} contact(s) matched.",
|
||||
query,
|
||||
matches.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected list_contacts: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `contact add`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Contacts/CreateContact` API. Persists directly to
|
||||
/// the daemon's own store (never a CardDAV write-back), so the created
|
||||
/// contact survives past this CLI process exiting and is visible to a
|
||||
/// later, separate `contact list` invocation against the same daemon.
|
||||
async fn handle_contact_add(
|
||||
&self,
|
||||
account: &str,
|
||||
name: &str,
|
||||
email: &str,
|
||||
org: Option<&str>,
|
||||
json_mode: bool,
|
||||
) -> String {
|
||||
let mut client = match self.connect_contacts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.create_contact(nuncio_proto::v1::CreateContactRequest {
|
||||
account_id: account.to_string(),
|
||||
display_name: name.to_string(),
|
||||
organization: org.map(str::to_string),
|
||||
emails: vec![nuncio_proto::v1::ContactEmail {
|
||||
email: email.to_string(),
|
||||
label: "work".to_string(),
|
||||
is_primary: true,
|
||||
}],
|
||||
phones: Vec::new(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.into_inner().contact {
|
||||
Some(contact) => {
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"status": "contact_created",
|
||||
"contact": contact_proto_to_json(&contact),
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"✓ Contact '{}' saved to address book.",
|
||||
contact.display_name
|
||||
)
|
||||
}
|
||||
}
|
||||
None => Self::render_error(
|
||||
"nunciod daemon accepted create_contact but returned no contact",
|
||||
json_mode,
|
||||
),
|
||||
},
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected create_contact: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `contact sync`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Contacts/Sync` API. With no persisted per-account
|
||||
/// CardDAV configuration, the daemon's production `Sync` path returns an
|
||||
/// honest error -- this NEVER prints a fabricated success.
|
||||
async fn handle_contact_sync(&self, account: &str, json_mode: bool) -> String {
|
||||
let mut client = match self.connect_contacts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.sync(nuncio_proto::v1::ContactsSyncRequest {
|
||||
account_id: account.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let synced_count = response.into_inner().synced_count;
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"status": "contacts_sync_complete",
|
||||
"synced_count": synced_count,
|
||||
}))
|
||||
} else {
|
||||
format!("Contacts synchronization complete: {synced_count} contact(s) synced")
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected contacts 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 +2100,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 +2733,400 @@ 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.Contacts` gRPC server
|
||||
/// (mirroring the `Calendar` stub pattern above) and drives the real
|
||||
/// `HeadlessRunner`'s `contact list`/`search`/`add`/`sync` gRPC client
|
||||
/// paths against it.
|
||||
///
|
||||
/// Real persistence and honest error semantics are proven by
|
||||
/// `nunciod`'s own `grpc::tests`; this test exists purely to prove the
|
||||
/// CLI's connect + call + JSON-format happy path, including `contact
|
||||
/// search`'s client-side filtering over the stub's full
|
||||
/// `ListContacts` result.
|
||||
#[tokio::test]
|
||||
async fn contacts_rpcs_round_trip_over_grpc_to_a_stub_daemon() {
|
||||
use nuncio_proto::v1::contacts_server::{Contacts as ContactsService, ContactsServer};
|
||||
use nuncio_proto::v1::{
|
||||
Contact as ContactProto, ContactEmail as ContactEmailProto, ContactsSyncRequest,
|
||||
ContactsSyncResponse, CreateContactRequest, CreateContactResponse, GetContactRequest,
|
||||
GetContactResponse, ListContactsRequest, ListContactsResponse,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn stub_contact(id: &str, display_name: &str, org: &str, email: &str) -> ContactProto {
|
||||
ContactProto {
|
||||
id: id.to_string(),
|
||||
account_id: Some("acct-stub-1".to_string()),
|
||||
display_name: display_name.to_string(),
|
||||
given_name: None,
|
||||
family_name: None,
|
||||
organization: Some(org.to_string()),
|
||||
job_title: None,
|
||||
notes: None,
|
||||
avatar_url: None,
|
||||
emails: vec![ContactEmailProto {
|
||||
email: email.to_string(),
|
||||
label: "work".to_string(),
|
||||
is_primary: true,
|
||||
}],
|
||||
phones: vec![],
|
||||
is_favorite: false,
|
||||
interaction_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal test-only stub of `nuncio.v1.Contacts`: records the last
|
||||
/// `ListContactsRequest`/`ContactsSyncRequest`/`CreateContactRequest`
|
||||
/// it received (so this test can assert on exactly what the CLI
|
||||
/// sent over the wire) and otherwise returns fixed responses.
|
||||
#[derive(Default)]
|
||||
struct StubContacts {
|
||||
last_list: Arc<Mutex<Option<ListContactsRequest>>>,
|
||||
last_sync: Arc<Mutex<Option<ContactsSyncRequest>>>,
|
||||
last_create: Arc<Mutex<Option<CreateContactRequest>>>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl ContactsService for StubContacts {
|
||||
async fn sync(
|
||||
&self,
|
||||
request: tonic::Request<ContactsSyncRequest>,
|
||||
) -> Result<tonic::Response<ContactsSyncResponse>, tonic::Status> {
|
||||
let req = request.into_inner();
|
||||
*self.last_sync.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
|
||||
Ok(tonic::Response::new(ContactsSyncResponse {
|
||||
synced_count: 2,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_contacts(
|
||||
&self,
|
||||
request: tonic::Request<ListContactsRequest>,
|
||||
) -> Result<tonic::Response<ListContactsResponse>, tonic::Status> {
|
||||
let req = request.into_inner();
|
||||
*self.last_list.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
|
||||
Ok(tonic::Response::new(ListContactsResponse {
|
||||
contacts: vec![
|
||||
stub_contact("ct-stub-1", "Alice Stub", "Kof22", "alice@nuncio.mx"),
|
||||
stub_contact("ct-stub-2", "Bob Other", "OtherCo", "bob@nuncio.mx"),
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_contact(
|
||||
&self,
|
||||
request: tonic::Request<GetContactRequest>,
|
||||
) -> Result<tonic::Response<GetContactResponse>, tonic::Status> {
|
||||
let req = request.into_inner();
|
||||
if req.contact_id == "ct-stub-1" {
|
||||
Ok(tonic::Response::new(GetContactResponse {
|
||||
contact: Some(stub_contact(
|
||||
"ct-stub-1",
|
||||
"Alice Stub",
|
||||
"Kof22",
|
||||
"alice@nuncio.mx",
|
||||
)),
|
||||
}))
|
||||
} else {
|
||||
Err(tonic::Status::not_found(format!(
|
||||
"contact '{}' not found",
|
||||
req.contact_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_contact(
|
||||
&self,
|
||||
request: tonic::Request<CreateContactRequest>,
|
||||
) -> Result<tonic::Response<CreateContactResponse>, tonic::Status> {
|
||||
let req = request.into_inner();
|
||||
*self.last_create.lock().unwrap_or_else(|e| e.into_inner()) = Some(req.clone());
|
||||
Ok(tonic::Response::new(CreateContactResponse {
|
||||
contact: Some(stub_contact(
|
||||
"ct-stub-new",
|
||||
&req.display_name,
|
||||
req.organization.as_deref().unwrap_or(""),
|
||||
req.emails.first().map(|e| e.email.as_str()).unwrap_or(""),
|
||||
)),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
let stub = StubContacts::default();
|
||||
let list_probe = stub.last_list.clone();
|
||||
let sync_probe = stub.last_sync.clone();
|
||||
let create_probe = stub.last_create.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(ContactsServer::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");
|
||||
|
||||
// `contact list`: proves the CLI is a real gRPC client of
|
||||
// `Contacts/ListContacts` -- the exact account supplied reaches the
|
||||
// daemon over the wire, and the daemon's real returned contacts
|
||||
// (NOT a fabricated "0 contacts found") appear in the CLI's output.
|
||||
let contact_list = runner
|
||||
.execute_command(
|
||||
&Commands::Contact {
|
||||
action: ContactSubcommand::List {
|
||||
account: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(contact_list.contains("ct-stub-1"));
|
||||
assert!(contact_list.contains("Alice Stub"));
|
||||
let recorded_list = list_probe
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received a list_contacts request");
|
||||
assert_eq!(recorded_list.account_id, "acct-stub-1");
|
||||
|
||||
// `contact search`: proves the CLI filters the daemon's real
|
||||
// `ListContacts` result client-side by query, matching only the
|
||||
// contact whose organization contains "Kof22".
|
||||
let contact_search = runner
|
||||
.execute_command(
|
||||
&Commands::Contact {
|
||||
action: ContactSubcommand::Search {
|
||||
account: "acct-stub-1".to_string(),
|
||||
query: "kof22".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(contact_search.contains("ct-stub-1"));
|
||||
assert!(!contact_search.contains("ct-stub-2"));
|
||||
|
||||
// `contact add`: proves the CLI is a real gRPC client of
|
||||
// `Contacts/CreateContact` and reports the daemon's real persisted
|
||||
// contact, rather than fabricating a throwaway in-memory contact
|
||||
// the way this command used to.
|
||||
let contact_add = runner
|
||||
.execute_command(
|
||||
&Commands::Contact {
|
||||
action: ContactSubcommand::Add {
|
||||
account: "acct-stub-1".to_string(),
|
||||
name: "New Person".to_string(),
|
||||
email: "new.person@nuncio.mx".to_string(),
|
||||
org: Some("Acme".to_string()),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(contact_add.contains(r#""status":"contact_created""#));
|
||||
assert!(contact_add.contains("ct-stub-new"));
|
||||
let recorded_create = create_probe
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received a create_contact request");
|
||||
assert_eq!(recorded_create.account_id, "acct-stub-1");
|
||||
assert_eq!(recorded_create.display_name, "New Person");
|
||||
assert_eq!(recorded_create.organization.as_deref(), Some("Acme"));
|
||||
|
||||
// `contact sync`: proves the CLI is a real gRPC client of
|
||||
// `Contacts/Sync` and reports the daemon's real `synced_count`.
|
||||
let contact_sync = runner
|
||||
.execute_command(
|
||||
&Commands::Contact {
|
||||
action: ContactSubcommand::Sync {
|
||||
account: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(contact_sync.contains(r#""status":"contacts_sync_complete""#));
|
||||
assert!(contact_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");
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,10 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
@@ -21,3 +25,4 @@ async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
wiremock = { workspace = true }
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Protocol-agnostic async contacts backend trait definitions.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::carddav::CardDavError;
|
||||
use crate::models::Contact;
|
||||
|
||||
/// Protocol-agnostic contacts backend engine trait implemented by CardDAV and mock engines.
|
||||
///
|
||||
/// Mirrors `nuncio_cal::CalendarBackend`'s shape: `Send + Sync` so implementations can be held
|
||||
/// behind an `Arc` and shared across daemon tasks, and returning the crate's single
|
||||
/// [`CardDavError`] type so callers do not need to know whether a given implementation talks
|
||||
/// to a real server or an in-memory fixture.
|
||||
#[async_trait]
|
||||
pub trait ContactsBackend: Send + Sync {
|
||||
/// Fetch contacts belonging to `account_id`.
|
||||
async fn fetch_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError>;
|
||||
}
|
||||
@@ -1,35 +1,56 @@
|
||||
use crate::models::Contact;
|
||||
use reqwest::Client;
|
||||
//! CardDAV (RFC 6352) `REPORT` client and multistatus response parser.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
use crate::backend::ContactsBackend;
|
||||
use crate::models::Contact;
|
||||
use crate::parser::VCardParserAdapter;
|
||||
|
||||
/// Errors returned by the CardDAV client engine.
|
||||
#[derive(Error, Debug, PartialEq, Eq)]
|
||||
pub enum CardDavError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("CardDAV sync error: {0}")]
|
||||
SyncError(String),
|
||||
/// Failed to parse an RFC 6350 vCard payload embedded in a CardDAV response.
|
||||
#[error("failed to parse vCard payload: {0}")]
|
||||
ParseFailed(String),
|
||||
|
||||
/// CardDAV network/transport-layer failure (connection, TLS, or an unexpected HTTP
|
||||
/// status) distinct from a payload that connected fine but failed to parse.
|
||||
#[error("CardDAV transport failure: {0}")]
|
||||
TransportFailed(String),
|
||||
}
|
||||
|
||||
/// Configuration for a specific CardDAV address book collection endpoint, mirroring
|
||||
/// `nuncio_cal::CalDavAccountConfig`.
|
||||
///
|
||||
/// `carddav_url` must already resolve to a specific address book collection (e.g.
|
||||
/// `https://carddav.example.com/dav/addressbooks/user/jmaes/contacts/`) -- PROPFIND-based
|
||||
/// `addressbook-home-set` auto-discovery is out of scope for this client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardDavAccountConfig {
|
||||
/// Nuncio account identifier this address book collection belongs to.
|
||||
pub account_id: String,
|
||||
/// Fully-qualified URL of the CardDAV address book collection to query.
|
||||
pub carddav_url: String,
|
||||
/// Basic-auth username (or app-specific username, per provider).
|
||||
pub username: String,
|
||||
/// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the
|
||||
/// caller -- never stored anywhere else in plaintext.
|
||||
pub auth_token: String,
|
||||
}
|
||||
|
||||
/// CardDAV client protocol engine managing address book `REPORT` queries against a real server.
|
||||
pub struct CardDavClient {
|
||||
#[allow(dead_code)]
|
||||
client: Client,
|
||||
config: CardDavAccountConfig,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CardDavClient {
|
||||
/// Create a new `CardDavClient` bound to a specific CardDAV address book collection.
|
||||
pub fn new(config: CardDavAccountConfig) -> Self {
|
||||
Self {
|
||||
client: Client::builder()
|
||||
http: reqwest::Client::builder()
|
||||
.user_agent("Nuncio-Contacts-CardDAV/1.0")
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
@@ -37,13 +58,153 @@ impl CardDavClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_vcards(&self) -> Result<Vec<Contact>, CardDavError> {
|
||||
info!(
|
||||
"Initiating CardDAV PROPFIND fetch from {}",
|
||||
self.config.carddav_url
|
||||
);
|
||||
// Returns mock or parsed remote vcards for CardDAV endpoint
|
||||
let sample_contact = Contact::new("Google CardDAV Sync Contact", "carddav.sync@nuncio.mx");
|
||||
Ok(vec![sample_contact])
|
||||
/// Construct a standard CardDAV `<card:addressbook-query>` XML payload (RFC 6352 Section
|
||||
/// 8.6) requesting the full `address-data` of every card in the collection.
|
||||
pub fn build_report_query() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
<card:address-data />
|
||||
</d:prop>
|
||||
</card:addressbook-query>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a CardDAV WebDAV XML `<multistatus>` response containing embedded vCard data.
|
||||
pub fn parse_multistatus_response(
|
||||
&self,
|
||||
account_id: &str,
|
||||
raw_xml: &str,
|
||||
) -> Result<Vec<Contact>, CardDavError> {
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
for block in raw_xml.split("<card:address-data>") {
|
||||
if let Some((vcard_data, _)) = block.split_once("</card:address-data>") {
|
||||
let clean_vcard = vcard_data.trim();
|
||||
if !clean_vcard.is_empty() {
|
||||
let contact_id = format!("carddav-{account_id}-{}", contacts.len() + 1);
|
||||
let contact =
|
||||
VCardParserAdapter::parse_vcard(&contact_id, account_id, clean_vcard)?;
|
||||
contacts.push(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// Issue a live CardDAV `REPORT` addressbook-query (RFC 6352 Section 8.6) against
|
||||
/// `self.config.carddav_url` and parse the `multistatus` response into domain contacts.
|
||||
///
|
||||
/// This performs a real network request -- no canned or fabricated data is ever
|
||||
/// returned. A server or network failure surfaces as [`CardDavError::TransportFailed`]; a
|
||||
/// malformed vCard inside a returned response surfaces as [`CardDavError::ParseFailed`].
|
||||
pub async fn fetch_remote_vcards(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<Contact>, CardDavError> {
|
||||
let body = Self::build_report_query();
|
||||
|
||||
// "REPORT" is a fixed, always-valid HTTP token; `from_bytes` cannot fail for it, but
|
||||
// the error is still propagated rather than unwrapped so no code path here can panic.
|
||||
let report_method = reqwest::Method::from_bytes(b"REPORT")
|
||||
.map_err(|e| CardDavError::TransportFailed(format!("invalid HTTP method: {e}")))?;
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.request(report_method, &self.config.carddav_url)
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.header("Depth", "1")
|
||||
.basic_auth(&self.config.username, Some(&self.config.auth_token))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CardDavError::TransportFailed(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
// RFC 6352 REPORT responses are conventionally 207 Multi-Status; some servers reply
|
||||
// 200 OK for a single-collection result, so any 2xx/207 status is accepted.
|
||||
if status.as_u16() != 207 && !status.is_success() {
|
||||
return Err(CardDavError::TransportFailed(format!(
|
||||
"CardDAV server returned unexpected status {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
let raw_xml = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| CardDavError::TransportFailed(e.to_string()))?;
|
||||
|
||||
self.parse_multistatus_response(account_id, &raw_xml)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContactsBackend for CardDavClient {
|
||||
async fn fetch_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError> {
|
||||
self.fetch_remote_vcards(account_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> CardDavAccountConfig {
|
||||
CardDavAccountConfig {
|
||||
account_id: "acct-1".to_string(),
|
||||
carddav_url: "https://carddav.example.com/addressbooks/contacts/".to_string(),
|
||||
username: "jmaes".to_string(),
|
||||
auth_token: "app-token-secret".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_report_query_targets_addressbook_query() {
|
||||
let query = CardDavClient::build_report_query();
|
||||
assert!(query.contains("<card:addressbook-query"));
|
||||
assert!(query.contains("<card:address-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multistatus_response_extracts_contacts() {
|
||||
let client = CardDavClient::new(test_config());
|
||||
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/alice.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Alice Dev
|
||||
EMAIL:alice@nuncio.mx
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#;
|
||||
|
||||
let contacts = client
|
||||
.parse_multistatus_response("acct-1", xml_response)
|
||||
.expect("parse succeeds");
|
||||
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].display_name, "Alice Dev");
|
||||
assert_eq!(contacts[0].emails[0].email, "alice@nuncio.mx");
|
||||
assert_eq!(contacts[0].account_id.as_deref(), Some("acct-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multistatus_response_with_no_cards_yields_empty_not_fabricated() {
|
||||
let client = CardDavClient::new(test_config());
|
||||
let xml_response = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"></d:multistatus>"#;
|
||||
|
||||
let contacts = client
|
||||
.parse_multistatus_response("acct-1", xml_response)
|
||||
.expect("parse succeeds");
|
||||
assert!(contacts.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
//! Contacts (CardDAV / RFC 6350 vCard) engine: a real CardDAV `REPORT` client, a vCard
|
||||
//! parser/generator round trip, and a protocol-agnostic [`ContactsBackend`] trait.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod backend;
|
||||
pub mod carddav;
|
||||
pub mod db;
|
||||
pub mod mock;
|
||||
pub mod models;
|
||||
pub mod parser;
|
||||
|
||||
pub use backend::ContactsBackend;
|
||||
pub use carddav::{CardDavAccountConfig, CardDavClient, CardDavError};
|
||||
pub use db::{ContactsDatabase, ContactsStoreError};
|
||||
pub use mock::MockContactsBackend;
|
||||
pub use models::{Contact, ContactEmail, ContactGroup, ContactPhone};
|
||||
pub use parser::VCardParserAdapter;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Deterministic mock contacts backend for offline testing and integration verification.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::backend::ContactsBackend;
|
||||
use crate::carddav::CardDavError;
|
||||
use crate::models::Contact;
|
||||
|
||||
/// Thread-safe mock contacts backend for offline testing.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockContactsBackend {
|
||||
contacts: Arc<Mutex<Vec<Contact>>>,
|
||||
should_fail: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl MockContactsBackend {
|
||||
/// Create a new `MockContactsBackend`.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Configure the mock to simulate CardDAV network failure errors.
|
||||
pub fn set_should_fail(&self, fail: bool) {
|
||||
if let Ok(mut flag) = self.should_fail.lock() {
|
||||
*flag = fail;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a mock contact to storage.
|
||||
pub fn add_contact(&self, contact: Contact) {
|
||||
if let Ok(mut guard) = self.contacts.lock() {
|
||||
guard.push(contact);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve contacts belonging to a specific account.
|
||||
pub fn list_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError> {
|
||||
let should_fail = self
|
||||
.should_fail
|
||||
.lock()
|
||||
.map_err(|e| CardDavError::ParseFailed(e.to_string()))?;
|
||||
if *should_fail {
|
||||
return Err(CardDavError::TransportFailed(
|
||||
"simulated CardDAV network failure".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let guard = self
|
||||
.contacts
|
||||
.lock()
|
||||
.map_err(|e| CardDavError::ParseFailed(e.to_string()))?;
|
||||
let matches = guard
|
||||
.iter()
|
||||
.filter(|c| c.account_id.as_deref() == Some(account_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ContactsBackend for MockContactsBackend {
|
||||
async fn fetch_contacts(&self, account_id: &str) -> Result<Vec<Contact>, CardDavError> {
|
||||
self.list_contacts(account_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::Contact as ModelContact;
|
||||
|
||||
fn sample_contact(account_id: &str, display_name: &str) -> ModelContact {
|
||||
let mut contact = ModelContact::new(display_name, format!("{display_name}@nuncio.mx"));
|
||||
contact.account_id = Some(account_id.to_string());
|
||||
contact
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_contacts_backend_operations() {
|
||||
let mock = MockContactsBackend::new();
|
||||
mock.add_contact(sample_contact("acct-1", "Mock Contact"));
|
||||
|
||||
let contacts = mock
|
||||
.list_contacts("acct-1")
|
||||
.expect("list contacts succeeds");
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].display_name, "Mock Contact");
|
||||
|
||||
assert!(mock
|
||||
.list_contacts("acct-other")
|
||||
.expect("list succeeds")
|
||||
.is_empty());
|
||||
|
||||
mock.set_should_fail(true);
|
||||
assert!(mock.list_contacts("acct-1").is_err());
|
||||
}
|
||||
|
||||
/// Proves `MockContactsBackend` is usable behind the `ContactsBackend` trait object, the
|
||||
/// same way the daemon will inject either it or the real CardDAV client.
|
||||
#[tokio::test]
|
||||
async fn mock_contacts_backend_is_usable_as_a_trait_object() {
|
||||
let mock = MockContactsBackend::new();
|
||||
mock.add_contact(sample_contact("acct-1", "Trait Object Contact"));
|
||||
|
||||
let backend: Arc<dyn ContactsBackend> = Arc::new(mock);
|
||||
let contacts = backend
|
||||
.fetch_contacts("acct-1")
|
||||
.await
|
||||
.expect("fetch contacts succeeds");
|
||||
assert_eq!(contacts.len(), 1);
|
||||
assert_eq!(contacts[0].display_name, "Trait Object Contact");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! vCard (RFC 6350) parser adapter converting raw vCard payloads into Nuncio [`Contact`]
|
||||
//! domain entities -- the read half of the round trip whose write half is
|
||||
//! [`Contact::to_vcard`].
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::carddav::CardDavError;
|
||||
use crate::models::{Contact, ContactEmail, ContactPhone};
|
||||
|
||||
/// vCard parser adapter converting raw `.vcf`/CardDAV `address-data` payloads into Nuncio
|
||||
/// [`Contact`] domain entities.
|
||||
pub struct VCardParserAdapter;
|
||||
|
||||
impl VCardParserAdapter {
|
||||
/// Maximum allowed single-vCard payload size (1MB -- generous for a single contact
|
||||
/// record, small enough to reject a runaway/malicious response body outright).
|
||||
pub const MAX_PAYLOAD_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Parse a raw RFC 6350 vCard string into a [`Contact`] entity.
|
||||
///
|
||||
/// `id` is assigned by the caller (CardDAV responses carry no stable contact ID of their
|
||||
/// own outside the enclosing WebDAV `href`); `account_id` tags which Nuncio account this
|
||||
/// contact was fetched for. Never fabricates a placeholder name or email: a vCard with
|
||||
/// neither `FN` nor any `EMAIL` is a parse failure, not an empty/default contact.
|
||||
pub fn parse_vcard(
|
||||
id: &str,
|
||||
account_id: &str,
|
||||
raw_vcard: &str,
|
||||
) -> Result<Contact, CardDavError> {
|
||||
if raw_vcard.len() > Self::MAX_PAYLOAD_BYTES {
|
||||
return Err(CardDavError::ParseFailed(
|
||||
"vCard payload exceeds maximum allowed limit of 1MB".to_string(),
|
||||
));
|
||||
}
|
||||
if !raw_vcard.to_ascii_uppercase().contains("BEGIN:VCARD") {
|
||||
return Err(CardDavError::ParseFailed(
|
||||
"vCard payload is missing BEGIN:VCARD".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let unfolded = unfold_lines(raw_vcard);
|
||||
|
||||
let mut display_name: Option<String> = None;
|
||||
let mut given_name: Option<String> = None;
|
||||
let mut family_name: Option<String> = None;
|
||||
let mut organization: Option<String> = None;
|
||||
let mut job_title: Option<String> = None;
|
||||
let mut emails: Vec<ContactEmail> = Vec::new();
|
||||
let mut phones: Vec<ContactPhone> = Vec::new();
|
||||
|
||||
for line in unfolded.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let upper = line.to_ascii_uppercase();
|
||||
if upper == "BEGIN:VCARD" || upper == "END:VCARD" || upper.starts_with("VERSION:") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((prop, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let mut segments = prop.split(';');
|
||||
let name = segments.next().unwrap_or("").to_ascii_uppercase();
|
||||
let params: Vec<&str> = segments.collect();
|
||||
|
||||
match name.as_str() {
|
||||
"FN" => display_name = Some(value.trim().to_string()),
|
||||
"N" => {
|
||||
let comps: Vec<&str> = value.split(';').collect();
|
||||
if let Some(family) = comps.first().map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
{
|
||||
family_name = Some(family.to_string());
|
||||
}
|
||||
if let Some(given) = comps.get(1).map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
given_name = Some(given.to_string());
|
||||
}
|
||||
}
|
||||
"ORG" => organization = Some(value.trim().to_string()),
|
||||
"TITLE" => job_title = Some(value.trim().to_string()),
|
||||
"EMAIL" => {
|
||||
let label = extract_type(¶ms).unwrap_or_else(|| "other".to_string());
|
||||
let is_primary = emails.is_empty();
|
||||
emails.push(ContactEmail {
|
||||
email: value.trim().to_string(),
|
||||
label: label.to_ascii_lowercase(),
|
||||
is_primary,
|
||||
});
|
||||
}
|
||||
"TEL" => {
|
||||
let label = extract_type(¶ms).unwrap_or_else(|| "other".to_string());
|
||||
let is_primary = phones.is_empty();
|
||||
phones.push(ContactPhone {
|
||||
phone: value.trim().to_string(),
|
||||
label: label.to_ascii_lowercase(),
|
||||
is_primary,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let display_name = display_name
|
||||
.or_else(|| emails.first().map(|e| e.email.clone()))
|
||||
.ok_or_else(|| {
|
||||
CardDavError::ParseFailed(
|
||||
"vCard has neither FN nor EMAIL to derive a display name".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let now = Utc::now();
|
||||
Ok(Contact {
|
||||
id: id.to_string(),
|
||||
account_id: Some(account_id.to_string()),
|
||||
display_name,
|
||||
given_name,
|
||||
family_name,
|
||||
organization,
|
||||
job_title,
|
||||
notes: None,
|
||||
avatar_url: None,
|
||||
emails,
|
||||
phones,
|
||||
is_favorite: false,
|
||||
interaction_count: 0,
|
||||
last_interacted_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a `TYPE=...` parameter value from a vCard property's parameter list.
|
||||
fn extract_type(params: &[&str]) -> Option<String> {
|
||||
params.iter().find_map(|param| {
|
||||
param
|
||||
.to_ascii_uppercase()
|
||||
.strip_prefix("TYPE=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Unfold RFC 6350 line continuations (a line starting with a space/tab is a continuation of
|
||||
/// the previous line) into single logical lines, tolerating both CRLF and bare LF input.
|
||||
fn unfold_lines(raw: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for line in raw.split("\r\n").flat_map(|l| l.split('\n')) {
|
||||
if (line.starts_with(' ') || line.starts_with('\t')) && !result.is_empty() {
|
||||
result.push_str(line.trim_start());
|
||||
} else {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(line);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_with_all_supported_fields() {
|
||||
let vcard = "BEGIN:VCARD\r\n\
|
||||
VERSION:4.0\r\n\
|
||||
FN:James Maes\r\n\
|
||||
N:Maes;James;;;\r\n\
|
||||
ORG:KofTwentyTwo\r\n\
|
||||
TITLE:Engineer\r\n\
|
||||
EMAIL;TYPE=WORK:james.maes@kof22.com\r\n\
|
||||
TEL;TYPE=MOBILE:+1-555-0199\r\n\
|
||||
END:VCARD";
|
||||
|
||||
let contact =
|
||||
VCardParserAdapter::parse_vcard("c-1", "acct-1", vcard).expect("parse succeeds");
|
||||
|
||||
assert_eq!(contact.display_name, "James Maes");
|
||||
assert_eq!(contact.given_name.as_deref(), Some("James"));
|
||||
assert_eq!(contact.family_name.as_deref(), Some("Maes"));
|
||||
assert_eq!(contact.organization.as_deref(), Some("KofTwentyTwo"));
|
||||
assert_eq!(contact.job_title.as_deref(), Some("Engineer"));
|
||||
assert_eq!(contact.emails.len(), 1);
|
||||
assert_eq!(contact.emails[0].email, "james.maes@kof22.com");
|
||||
assert_eq!(contact.emails[0].label, "work");
|
||||
assert!(contact.emails[0].is_primary);
|
||||
assert_eq!(contact.phones.len(), 1);
|
||||
assert_eq!(contact.phones[0].phone, "+1-555-0199");
|
||||
assert_eq!(contact.phones[0].label, "mobile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_without_fn_falls_back_to_primary_email_as_display_name() {
|
||||
let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nEMAIL:alice@nuncio.mx\r\nEND:VCARD";
|
||||
let contact =
|
||||
VCardParserAdapter::parse_vcard("c-2", "acct-1", vcard).expect("parse succeeds");
|
||||
assert_eq!(contact.display_name, "alice@nuncio.mx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_missing_begin_marker_is_a_real_error() {
|
||||
let err = VCardParserAdapter::parse_vcard("c-3", "acct-1", "FN:No Envelope")
|
||||
.expect_err("must reject a payload with no BEGIN:VCARD");
|
||||
assert!(matches!(err, CardDavError::ParseFailed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vcard_with_no_name_or_email_is_a_real_error_not_a_fabricated_contact() {
|
||||
let vcard = "BEGIN:VCARD\r\nVERSION:4.0\r\nORG:Ghost Corp\r\nEND:VCARD";
|
||||
let err = VCardParserAdapter::parse_vcard("c-4", "acct-1", vcard)
|
||||
.expect_err("a vCard with no FN and no EMAIL must never become a fabricated contact");
|
||||
assert!(matches!(err, CardDavError::ParseFailed(_)));
|
||||
}
|
||||
|
||||
/// Proves the parser is the true inverse of [`Contact::to_vcard`]: generating a vCard from
|
||||
/// a rich domain [`Contact`] and parsing it back must reproduce the same
|
||||
/// FN/N/ORG/TITLE/primary-EMAIL/primary-TEL fields.
|
||||
#[test]
|
||||
fn vcard_round_trip_preserves_core_fields() {
|
||||
let mut original = Contact::new("Alice Developer", "alice@kof22.com");
|
||||
original.given_name = Some("Alice".to_string());
|
||||
original.family_name = Some("Developer".to_string());
|
||||
original.organization = Some("KofTwentyTwo".to_string());
|
||||
original.job_title = Some("Staff Engineer".to_string());
|
||||
original.phones.push(ContactPhone {
|
||||
phone: "+1-555-0100".to_string(),
|
||||
label: "mobile".to_string(),
|
||||
is_primary: true,
|
||||
});
|
||||
|
||||
let vcard = original.to_vcard();
|
||||
let round_tripped = VCardParserAdapter::parse_vcard("c-rt-1", "acct-1", &vcard)
|
||||
.expect("round-trip parse succeeds");
|
||||
|
||||
assert_eq!(round_tripped.display_name, original.display_name);
|
||||
assert_eq!(round_tripped.given_name, original.given_name);
|
||||
assert_eq!(round_tripped.family_name, original.family_name);
|
||||
assert_eq!(round_tripped.organization, original.organization);
|
||||
assert_eq!(round_tripped.job_title, original.job_title);
|
||||
assert_eq!(round_tripped.emails[0].email, original.emails[0].email);
|
||||
assert_eq!(round_tripped.emails[0].label, original.emails[0].label);
|
||||
assert_eq!(round_tripped.phones[0].phone, original.phones[0].phone);
|
||||
assert_eq!(round_tripped.phones[0].label, original.phones[0].label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! WireMock integration test suite for the real CardDAV `REPORT` transport.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_contacts::{CardDavAccountConfig, CardDavClient, CardDavError, ContactsBackend};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// A canned CardDAV `multistatus` response containing two real vCards, exercising the real
|
||||
/// HTTP transport together with the vCard parser.
|
||||
fn multistatus_body() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/james.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:James Maes
|
||||
N:Maes;James;;;
|
||||
ORG:KofTwentyTwo
|
||||
TITLE:Founder
|
||||
EMAIL;TYPE=WORK:james.maes@kof22.com
|
||||
TEL;TYPE=MOBILE:+1-555-0199
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/alice.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Alice Dev
|
||||
EMAIL:alice@nuncio.mx
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_carddav_report_fetches_and_parses_vcards() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/addressbooks/contacts/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body()))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CardDavAccountConfig {
|
||||
account_id: "acct-wm-1".to_string(),
|
||||
carddav_url: format!("{}/addressbooks/contacts/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CardDavClient::new(config);
|
||||
|
||||
// Drive the real client through the `ContactsBackend` trait, exactly how the daemon will
|
||||
// invoke either this real client or `MockContactsBackend` behind the same seam.
|
||||
let contacts: Vec<_> = ContactsBackend::fetch_contacts(&client, "acct-wm-1")
|
||||
.await
|
||||
.expect("fetch_contacts succeeds against the wiremock server");
|
||||
|
||||
assert_eq!(contacts.len(), 2);
|
||||
|
||||
let james = contacts
|
||||
.iter()
|
||||
.find(|c| c.display_name == "James Maes")
|
||||
.expect("James Maes contact present");
|
||||
assert_eq!(james.account_id.as_deref(), Some("acct-wm-1"));
|
||||
assert_eq!(james.given_name.as_deref(), Some("James"));
|
||||
assert_eq!(james.family_name.as_deref(), Some("Maes"));
|
||||
assert_eq!(james.organization.as_deref(), Some("KofTwentyTwo"));
|
||||
assert_eq!(james.job_title.as_deref(), Some("Founder"));
|
||||
assert_eq!(james.emails[0].email, "james.maes@kof22.com");
|
||||
assert_eq!(james.phones[0].phone, "+1-555-0199");
|
||||
|
||||
let alice = contacts
|
||||
.iter()
|
||||
.find(|c| c.display_name == "Alice Dev")
|
||||
.expect("Alice Dev contact present");
|
||||
assert_eq!(alice.emails[0].email, "alice@nuncio.mx");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_carddav_report_server_error_surfaces_as_transport_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/addressbooks/broken/"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CardDavAccountConfig {
|
||||
account_id: "acct-wm-2".to_string(),
|
||||
carddav_url: format!("{}/addressbooks/broken/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CardDavClient::new(config);
|
||||
|
||||
let err = client
|
||||
.fetch_remote_vcards("acct-wm-2")
|
||||
.await
|
||||
.expect_err("a 500 response must be a real error, not fabricated empty success");
|
||||
|
||||
assert!(matches!(err, CardDavError::TransportFailed(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiremock_carddav_report_malformed_vcard_surfaces_as_parse_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
let malformed_body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/addressbooks/malformed/ghost.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
ORG:No Name Or Email
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#;
|
||||
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/addressbooks/malformed/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(malformed_body))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = CardDavAccountConfig {
|
||||
account_id: "acct-wm-3".to_string(),
|
||||
carddav_url: format!("{}/addressbooks/malformed/", mock_server.uri()),
|
||||
username: "james.maes".to_string(),
|
||||
auth_token: "wiremock-app-token".to_string(),
|
||||
};
|
||||
let client = CardDavClient::new(config);
|
||||
|
||||
let err = client
|
||||
.fetch_remote_vcards("acct-wm-3")
|
||||
.await
|
||||
.expect_err("a vCard with neither FN nor EMAIL must be a real parse error");
|
||||
|
||||
assert!(matches!(err, CardDavError::ParseFailed(_)));
|
||||
}
|
||||
Binary file not shown.
@@ -644,6 +644,243 @@ 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;
|
||||
}
|
||||
|
||||
// Contacts exposes the READ path over the daemon's real, persistent contact
|
||||
// store (`DatabaseEngine::save_contact`/`list_contacts`/`get_contact`), plus
|
||||
// a real `Sync` that fetches contacts from a `ContactsBackend` and persists
|
||||
// them, and a `CreateContact` that persists a locally-authored contact
|
||||
// directly to that same store.
|
||||
//
|
||||
// This is an honest surface: there is currently no way to persist a
|
||||
// per-account CardDAV address book collection URL/credential, so `Sync` in
|
||||
// production (no injected test backend) has no real CardDAV connection to
|
||||
// make and returns an error rather than a fabricated success.
|
||||
// `CreateContact` is NOT a CardDAV write-back -- it only ever writes to the
|
||||
// daemon's own local store, the same way the address book has always worked
|
||||
// here, just now actually persisted through the daemon's real store instead
|
||||
// of a disposable per-process in-memory database. There is deliberately no
|
||||
// RPC to delete a contact or to push a locally-authored contact back to a
|
||||
// remote CardDAV server.
|
||||
//
|
||||
// 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 Contacts {
|
||||
// Sync fetches contacts for `account_id` from the account's contacts
|
||||
// backend and persists them via the store. With no per-account CardDAV
|
||||
// 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(ContactsSyncRequest) returns (ContactsSyncResponse);
|
||||
// ListContacts returns persisted contacts for `account_id`, ordered by
|
||||
// interaction count then display name -- always genuinely persisted data,
|
||||
// never fabricated.
|
||||
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse);
|
||||
// GetContact returns a single persisted contact by ID.
|
||||
rpc GetContact(GetContactRequest) returns (GetContactResponse);
|
||||
// CreateContact persists a new, locally-authored contact directly to the
|
||||
// daemon's store. This is real local persistence, not a fabricated CardDAV
|
||||
// write-back -- the created contact is never pushed to a remote server.
|
||||
rpc CreateContact(CreateContactRequest) returns (CreateContactResponse);
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_contacts::models::ContactEmail`.
|
||||
message ContactEmail {
|
||||
// Email address.
|
||||
string email = 1;
|
||||
// Address label (e.g. "work", "home", "other").
|
||||
string label = 2;
|
||||
// Whether this is the contact's primary email address.
|
||||
bool is_primary = 3;
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_contacts::models::ContactPhone`.
|
||||
message ContactPhone {
|
||||
// Phone number.
|
||||
string phone = 1;
|
||||
// Number label (e.g. "mobile", "work", "home").
|
||||
string label = 2;
|
||||
// Whether this is the contact's primary phone number.
|
||||
bool is_primary = 3;
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_contacts::models::Contact` field-for-field, except the
|
||||
// internal bookkeeping timestamps (`created_at`/`updated_at`/
|
||||
// `last_interacted_at`) are deliberately omitted -- no real client use case
|
||||
// for them was identified for this vertical; they can be added additively
|
||||
// later if one arises.
|
||||
message Contact {
|
||||
// Contact unique identifier.
|
||||
string id = 1;
|
||||
// Account identifier owning this contact's address book entry.
|
||||
optional string account_id = 2;
|
||||
// Display name.
|
||||
string display_name = 3;
|
||||
// Given (first) name.
|
||||
optional string given_name = 4;
|
||||
// Family (last) name.
|
||||
optional string family_name = 5;
|
||||
// Organization / company name.
|
||||
optional string organization = 6;
|
||||
// Job title.
|
||||
optional string job_title = 7;
|
||||
// Free-form notes.
|
||||
optional string notes = 8;
|
||||
// Avatar image URL.
|
||||
optional string avatar_url = 9;
|
||||
// Email addresses.
|
||||
repeated ContactEmail emails = 10;
|
||||
// Phone numbers.
|
||||
repeated ContactPhone phones = 11;
|
||||
// Favorite/starred flag.
|
||||
bool is_favorite = 12;
|
||||
// Number of recorded interactions with this contact.
|
||||
uint64 interaction_count = 13;
|
||||
}
|
||||
|
||||
// ContactsSyncRequest mirrors `SyncRequest`'s shape (unlike Calendar's
|
||||
// collection-scoped sync) since `ContactsBackend::fetch_contacts` is
|
||||
// account-scoped, not collection-scoped.
|
||||
message ContactsSyncRequest {
|
||||
string account_id = 1;
|
||||
}
|
||||
|
||||
// ContactsSyncResponse reports how many contacts were synchronized.
|
||||
message ContactsSyncResponse {
|
||||
uint64 synced_count = 1;
|
||||
}
|
||||
|
||||
// ListContactsRequest. Contacts are always scoped to the account whose
|
||||
// address book they belong to, mirroring the store's `list_contacts`
|
||||
// signature.
|
||||
message ListContactsRequest {
|
||||
string account_id = 1;
|
||||
}
|
||||
|
||||
// ListContactsResponse.
|
||||
message ListContactsResponse {
|
||||
repeated Contact contacts = 1;
|
||||
}
|
||||
|
||||
// GetContactRequest.
|
||||
message GetContactRequest {
|
||||
string contact_id = 1;
|
||||
}
|
||||
|
||||
// GetContactResponse.
|
||||
message GetContactResponse {
|
||||
Contact contact = 1;
|
||||
}
|
||||
|
||||
// CreateContactRequest carries the fields needed to author a brand-new local
|
||||
// contact. `emails`/`phones` follow `Contact`'s shape so multiple addresses
|
||||
// could be supplied, though the CLI's `contact add` command only ever sends
|
||||
// a single primary email today.
|
||||
message CreateContactRequest {
|
||||
string account_id = 1;
|
||||
string display_name = 2;
|
||||
optional string organization = 3;
|
||||
repeated ContactEmail emails = 4;
|
||||
repeated ContactPhone phones = 5;
|
||||
}
|
||||
|
||||
// CreateContactResponse carries the persisted contact, including its
|
||||
// daemon-assigned `id`.
|
||||
message CreateContactResponse {
|
||||
Contact contact = 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,8 @@
|
||||
|
||||
use crate::v1::accounts_client::AccountsClient;
|
||||
use crate::v1::audit_client::AuditClient;
|
||||
use crate::v1::calendar_client::CalendarClient;
|
||||
use crate::v1::contacts_client::ContactsClient;
|
||||
use crate::v1::export_client::ExportClient;
|
||||
use crate::v1::filters_client::FiltersClient;
|
||||
use crate::v1::mail_client::MailClient;
|
||||
@@ -112,6 +114,16 @@ 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>>;
|
||||
|
||||
/// The authenticated `nuncio.v1.Contacts` client type returned by
|
||||
/// [`connect_contacts`].
|
||||
pub type AuthenticatedContactsClient =
|
||||
ContactsClient<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 +250,46 @@ 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))
|
||||
}
|
||||
|
||||
/// Dials the `nuncio.v1.Contacts` gRPC endpoint at `addr` and returns a
|
||||
/// client that injects `authorization: Bearer <token>` metadata on every
|
||||
/// call.
|
||||
///
|
||||
/// `Contacts` 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`] / [`connect_calendar`] rather than
|
||||
/// hand-rolling yet another auth handshake.
|
||||
pub async fn connect_contacts(
|
||||
addr: &str,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedContactsClient, ConnectError> {
|
||||
let interceptor = BearerTokenInterceptor::new(token)?;
|
||||
let channel = dial(addr).await?;
|
||||
Ok(ContactsClient::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 +448,40 @@ 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_contacts_fails_closed_on_invalid_token() {
|
||||
let err = connect_contacts("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_contacts_reports_transport_error_when_daemon_unreachable() {
|
||||
let err = connect_contacts("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
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
[dependencies]
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
tokio.workspace = true
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -115,6 +115,52 @@ fn resolve_engine_keys(
|
||||
Ok((storage_key, worm_key, ledger_key))
|
||||
}
|
||||
|
||||
/// Reconstruct a [`nuncio_contacts::Contact`] from a `contacts` table row.
|
||||
///
|
||||
/// `created_at`/`updated_at`/`last_interacted_at` are stored as RFC 3339 strings written by
|
||||
/// [`DatabaseEngine::save_contact`] itself, so a parse failure here would indicate corrupted
|
||||
/// storage rather than bad input; falling back to "now" for that edge case (rather than
|
||||
/// propagating a parse error through every read) mirrors the tolerant `unwrap_or_default`
|
||||
/// JSON-decode style already used for `emails_json`/`phones_json` below.
|
||||
fn contact_from_row(row: &sqlx::sqlite::SqliteRow) -> nuncio_contacts::Contact {
|
||||
let emails_json: String = row.get("emails_json");
|
||||
let phones_json: String = row.get("phones_json");
|
||||
let emails: Vec<nuncio_contacts::ContactEmail> =
|
||||
serde_json::from_str(&emails_json).unwrap_or_default();
|
||||
let phones: Vec<nuncio_contacts::ContactPhone> =
|
||||
serde_json::from_str(&phones_json).unwrap_or_default();
|
||||
let is_favorite: i64 = row.get("is_favorite");
|
||||
let interaction_count: i64 = row.get("interaction_count");
|
||||
let last_interacted_at: Option<String> = row.get("last_interacted_at");
|
||||
let created_at: String = row.get("created_at");
|
||||
let updated_at: String = row.get("updated_at");
|
||||
|
||||
let parse_rfc3339 = |s: &str| -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now())
|
||||
};
|
||||
|
||||
nuncio_contacts::Contact {
|
||||
id: row.get("id"),
|
||||
account_id: row.get("account_id"),
|
||||
display_name: row.get("display_name"),
|
||||
given_name: row.get("given_name"),
|
||||
family_name: row.get("family_name"),
|
||||
organization: row.get("organization"),
|
||||
job_title: row.get("job_title"),
|
||||
notes: row.get("notes"),
|
||||
avatar_url: row.get("avatar_url"),
|
||||
emails,
|
||||
phones,
|
||||
is_favorite: is_favorite != 0,
|
||||
interaction_count: interaction_count as u64,
|
||||
last_interacted_at: last_interacted_at.as_deref().map(parse_rfc3339),
|
||||
created_at: parse_rfc3339(&created_at),
|
||||
updated_at: parse_rfc3339(&updated_at),
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured result of re-verifying the entire persisted WORM audit
|
||||
/// ledger's hash chain. See
|
||||
/// [`DatabaseEngine::verify_worm_audit_chain_report`].
|
||||
@@ -519,6 +565,54 @@ impl DatabaseEngine {
|
||||
INSERT INTO events_fts(id, summary, location)
|
||||
VALUES (new.id, new.summary, COALESCE(new.location, ''));
|
||||
END;
|
||||
|
||||
-- Contacts (nuncio_contacts::Contact). Emails/phones are stored as JSON columns
|
||||
-- (mirroring the schema already proven in nuncio-contacts's own, now-superseded
|
||||
-- ContactsDatabase) rather than normalized child tables, since a contact's email/
|
||||
-- phone list is always read and written as a whole with its parent record. Like
|
||||
-- calendar summary/location, none of this is encrypted at rest, so trigger-based
|
||||
-- FTS mirroring introduces no confidentiality regression.
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
account_id TEXT,
|
||||
display_name TEXT NOT NULL,
|
||||
given_name TEXT,
|
||||
family_name TEXT,
|
||||
organization TEXT,
|
||||
job_title TEXT,
|
||||
notes TEXT,
|
||||
avatar_url TEXT,
|
||||
emails_json TEXT NOT NULL DEFAULT '[]',
|
||||
phones_json TEXT NOT NULL DEFAULT '[]',
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
interaction_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_interacted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS contacts_fts USING fts5(
|
||||
id UNINDEXED,
|
||||
display_name,
|
||||
organization,
|
||||
emails_json,
|
||||
tokenize = 'trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS contacts_ai AFTER INSERT ON contacts BEGIN
|
||||
INSERT INTO contacts_fts(id, display_name, organization, emails_json)
|
||||
VALUES (new.id, new.display_name, COALESCE(new.organization, ''), new.emails_json);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS contacts_ad AFTER DELETE ON contacts BEGIN
|
||||
DELETE FROM contacts_fts WHERE id = old.id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS contacts_au AFTER UPDATE ON contacts BEGIN
|
||||
DELETE FROM contacts_fts WHERE id = old.id;
|
||||
INSERT INTO contacts_fts(id, display_name, organization, emails_json)
|
||||
VALUES (new.id, new.display_name, COALESCE(new.organization, ''), new.emails_json);
|
||||
END;
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
@@ -995,6 +1089,58 @@ impl DatabaseEngine {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Query every persisted recurring calendar event (non-null, non-empty `rrule`) for
|
||||
/// `account_id`, with **no** time-window filter -- a recurring master's own stored
|
||||
/// `start_time`/`end_time` can validly sit long before (or after) any given query
|
||||
/// window while its expanded occurrences still fall inside it, so callers that need
|
||||
/// to materialize occurrences for a window must expand every one of the account's
|
||||
/// recurring masters rather than pre-filtering by the master row's own timestamps.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub async fn list_recurring_calendar_events(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<nuncio_core::model::CalendarEvent>, DatabaseError> {
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, account_id, calendar_id, summary, start_time, end_time, rrule, location
|
||||
FROM calendar_events
|
||||
WHERE account_id = ? AND rrule IS NOT NULL AND rrule != ''
|
||||
ORDER BY start_time ASC
|
||||
"#,
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(id, account_id, calendar_id, summary, start_time, end_time, rrule, location)| {
|
||||
nuncio_core::model::CalendarEvent {
|
||||
id,
|
||||
account_id,
|
||||
calendar_id,
|
||||
summary,
|
||||
start_time,
|
||||
end_time,
|
||||
rrule,
|
||||
location,
|
||||
}
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Retrieve a single calendar event by ID.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub async fn get_calendar_event(
|
||||
@@ -1034,6 +1180,103 @@ impl DatabaseEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a [`nuncio_contacts::Contact`] to SQLite (INSERT OR REPLACE).
|
||||
///
|
||||
/// `nuncio-store` is otherwise limited to depending only on `nuncio-core` domain types;
|
||||
/// this method (and [`Self::list_contacts`]/[`Self::get_contact`]) is a deliberate,
|
||||
/// narrow exception, taking a direct dependency on `nuncio_contacts::Contact` because that
|
||||
/// type has not (yet) been promoted to `nuncio-core` the way `CalendarEvent`/`Email` have.
|
||||
pub async fn save_contact(
|
||||
&self,
|
||||
contact: &nuncio_contacts::Contact,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let emails_json =
|
||||
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string());
|
||||
let phones_json =
|
||||
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string());
|
||||
let last_interacted_str = contact.last_interacted_at.map(|t| t.to_rfc3339());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO contacts
|
||||
(id, account_id, display_name, given_name, family_name, organization, job_title,
|
||||
notes, avatar_url, emails_json, phones_json, is_favorite, interaction_count,
|
||||
last_interacted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&contact.id)
|
||||
.bind(&contact.account_id)
|
||||
.bind(&contact.display_name)
|
||||
.bind(&contact.given_name)
|
||||
.bind(&contact.family_name)
|
||||
.bind(&contact.organization)
|
||||
.bind(&contact.job_title)
|
||||
.bind(&contact.notes)
|
||||
.bind(&contact.avatar_url)
|
||||
.bind(&emails_json)
|
||||
.bind(&phones_json)
|
||||
.bind(contact.is_favorite)
|
||||
.bind(contact.interaction_count as i64)
|
||||
.bind(&last_interacted_str)
|
||||
.bind(contact.created_at.to_rfc3339())
|
||||
.bind(contact.updated_at.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query persisted contacts belonging to `account_id`, most-recently-interacted first.
|
||||
///
|
||||
/// See the confidentiality/dependency note on [`Self::save_contact`].
|
||||
pub async fn list_contacts(
|
||||
&self,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<nuncio_contacts::Contact>, DatabaseError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, account_id, display_name, given_name, family_name, organization,
|
||||
job_title, notes, avatar_url, emails_json, phones_json, is_favorite,
|
||||
interaction_count, last_interacted_at, created_at, updated_at
|
||||
FROM contacts
|
||||
WHERE account_id = ?
|
||||
ORDER BY interaction_count DESC, display_name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(rows.iter().map(contact_from_row).collect())
|
||||
}
|
||||
|
||||
/// Retrieve a single contact by ID.
|
||||
///
|
||||
/// See the confidentiality/dependency note on [`Self::save_contact`].
|
||||
pub async fn get_contact(
|
||||
&self,
|
||||
contact_id: &str,
|
||||
) -> Result<nuncio_contacts::Contact, DatabaseError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, account_id, display_name, given_name, family_name, organization,
|
||||
job_title, notes, avatar_url, emails_json, phones_json, is_favorite,
|
||||
interaction_count, last_interacted_at, created_at, updated_at
|
||||
FROM contacts
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(contact_from_row(&row))
|
||||
}
|
||||
|
||||
/// Update a single message's read/unread flag in place.
|
||||
///
|
||||
/// Returns `DatabaseError::Query(sqlx::Error::RowNotFound)` if no message
|
||||
@@ -1981,6 +2224,45 @@ mod tests {
|
||||
assert_eq!(hits[0].id, "evt-db-100");
|
||||
}
|
||||
|
||||
/// `list_recurring_calendar_events` returns only rows with a non-empty `rrule`, for the
|
||||
/// requested account only, and applies no time-window filter at all -- a master whose own
|
||||
/// stored `start_time` is far outside any given query window must still come back, since
|
||||
/// its expanded occurrences may fall inside that window.
|
||||
#[tokio::test]
|
||||
async fn list_recurring_calendar_events_filters_by_account_and_rrule_presence() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let mut recurring = sample_calendar_event("evt-recurring", "acct-1");
|
||||
recurring.rrule = Some("FREQ=WEEKLY;INTERVAL=1".to_string());
|
||||
recurring.start_time = 1_000; // far outside any window used below
|
||||
recurring.end_time = 4_600;
|
||||
engine.save_calendar_event(&recurring).await.unwrap();
|
||||
|
||||
let non_recurring = sample_calendar_event("evt-single", "acct-1");
|
||||
engine.save_calendar_event(&non_recurring).await.unwrap();
|
||||
|
||||
let mut other_account_recurring = sample_calendar_event("evt-other-acct", "acct-2");
|
||||
other_account_recurring.rrule = Some("FREQ=DAILY".to_string());
|
||||
engine
|
||||
.save_calendar_event(&other_account_recurring)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let recurring_for_acct1 = engine
|
||||
.list_recurring_calendar_events("acct-1")
|
||||
.await
|
||||
.expect("list recurring calendar events succeeds");
|
||||
assert_eq!(recurring_for_acct1.len(), 1);
|
||||
assert_eq!(recurring_for_acct1[0].id, "evt-recurring");
|
||||
|
||||
let recurring_for_acct2 = engine
|
||||
.list_recurring_calendar_events("acct-2")
|
||||
.await
|
||||
.expect("list recurring calendar events succeeds");
|
||||
assert_eq!(recurring_for_acct2.len(), 1);
|
||||
assert_eq!(recurring_for_acct2[0].id, "evt-other-acct");
|
||||
}
|
||||
|
||||
/// Re-saving an event with the same ID (an update, not a fresh insert) replaces both the
|
||||
/// row and its FTS entry rather than duplicating either.
|
||||
#[tokio::test]
|
||||
@@ -2026,6 +2308,104 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
fn sample_contact(id: &str, account_id: &str) -> nuncio_contacts::Contact {
|
||||
let mut contact = nuncio_contacts::Contact::new("Architecture Contact", "arch@nuncio.mx");
|
||||
contact.id = id.to_string();
|
||||
contact.account_id = Some(account_id.to_string());
|
||||
contact.organization = Some("KofTwentyTwo".to_string());
|
||||
contact
|
||||
}
|
||||
|
||||
/// Count rows in `contacts_fts` matching a trigram search term, proving the real write
|
||||
/// path (not a raw-SQL test insert) keeps the trigger-populated FTS index in sync -- the
|
||||
/// same proof pattern as `save_get_list_and_search_calendar_event`, without depending on
|
||||
/// `SearchEngine` gaining a contacts-specific method (out of scope for this story).
|
||||
async fn contacts_fts_hit_count(engine: &DatabaseEngine, term: &str) -> i64 {
|
||||
let pattern = format!("%{term}%");
|
||||
let row: (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM contacts_fts WHERE display_name LIKE ?")
|
||||
.bind(&pattern)
|
||||
.fetch_one(engine.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
row.0
|
||||
}
|
||||
|
||||
/// A saved contact is retrievable by both `get_contact` and `list_contacts`, and its FTS
|
||||
/// mirror is kept in sync by the real write path (not a raw-SQL test insert).
|
||||
#[tokio::test]
|
||||
async fn save_get_list_and_search_contact() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let contact = sample_contact("ct-db-100", "acct-1");
|
||||
engine
|
||||
.save_contact(&contact)
|
||||
.await
|
||||
.expect("save contact succeeds");
|
||||
|
||||
let fetched = engine
|
||||
.get_contact("ct-db-100")
|
||||
.await
|
||||
.expect("get contact succeeds");
|
||||
assert_eq!(fetched.display_name, contact.display_name);
|
||||
assert_eq!(fetched.account_id, contact.account_id);
|
||||
assert_eq!(fetched.emails[0].email, contact.emails[0].email);
|
||||
|
||||
let listed = engine
|
||||
.list_contacts("acct-1")
|
||||
.await
|
||||
.expect("list contacts succeeds");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, "ct-db-100");
|
||||
|
||||
// Different account -> no match.
|
||||
let other_account = engine
|
||||
.list_contacts("acct-other")
|
||||
.await
|
||||
.expect("list contacts succeeds");
|
||||
assert!(other_account.is_empty());
|
||||
|
||||
assert_eq!(contacts_fts_hit_count(&engine, "Architecture").await, 1);
|
||||
}
|
||||
|
||||
/// Re-saving a contact with the same ID (an update, not a fresh insert) replaces both the
|
||||
/// row and its FTS entry rather than duplicating either.
|
||||
#[tokio::test]
|
||||
async fn save_contact_upserts_existing_row_and_fts_entry() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let mut contact = sample_contact("ct-db-200", "acct-1");
|
||||
engine.save_contact(&contact).await.unwrap();
|
||||
|
||||
contact.display_name = "Renamed Contact".to_string();
|
||||
engine.save_contact(&contact).await.unwrap();
|
||||
|
||||
let fetched = engine.get_contact("ct-db-200").await.unwrap();
|
||||
assert_eq!(fetched.display_name, "Renamed Contact");
|
||||
|
||||
assert_eq!(
|
||||
contacts_fts_hit_count(&engine, "Architecture Contact").await,
|
||||
0
|
||||
);
|
||||
assert_eq!(contacts_fts_hit_count(&engine, "Renamed").await, 1);
|
||||
}
|
||||
|
||||
/// `get_contact` for an ID that was never saved reports `sqlx::Error::RowNotFound` rather
|
||||
/// than silently fabricating an empty/default contact.
|
||||
#[tokio::test]
|
||||
async fn get_contact_reports_not_found_for_missing_contact() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let err = engine
|
||||
.get_contact("ct-does-not-exist")
|
||||
.await
|
||||
.expect_err("missing contact must be a real error");
|
||||
assert!(matches!(
|
||||
err,
|
||||
DatabaseError::Query(sqlx::Error::RowNotFound)
|
||||
));
|
||||
}
|
||||
|
||||
/// `set_message_read` flips the persisted
|
||||
/// `read_flag` in place (both directions), and reports
|
||||
/// `sqlx::Error::RowNotFound` for a message ID that was never saved,
|
||||
|
||||
@@ -13,6 +13,7 @@ workspace = true
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
nuncio-mail = { path = "../nuncio-mail" }
|
||||
nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-proto = { path = "../nuncio-proto" }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Real inbound contacts synchronization routine.
|
||||
//!
|
||||
//! Mirrors `nunciod::calendar_sync`'s shape for the contacts engine:
|
||||
//! [`sync_with_backend`] fetches contacts from a [`ContactsBackend`] for a
|
||||
//! single account, then persists each contact via
|
||||
//! [`DatabaseEngine::save_contact`].
|
||||
//!
|
||||
//! # 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
|
||||
//! CardDAV address book collection URL and credential and builds a real
|
||||
//! `CardDavClient` from them: `nuncio_core::AccountConfig` carries no CardDAV
|
||||
//! 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 `MockContactsBackend`, and it is the same seam a future
|
||||
//! story's production entry point will call once per-account CardDAV
|
||||
//! configuration exists.
|
||||
use nuncio_contacts::ContactsBackend;
|
||||
use nuncio_store::db::DatabaseError;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur while synchronizing a single account's contacts.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ContactsSyncError {
|
||||
/// The contacts backend (CardDAV/mock) reported an error.
|
||||
#[error("contacts backend error: {0}")]
|
||||
Backend(#[from] nuncio_contacts::CardDavError),
|
||||
/// A database persistence error occurred while saving synced contacts.
|
||||
#[error("database error: {0}")]
|
||||
Store(#[from] DatabaseError),
|
||||
}
|
||||
|
||||
/// Fetch every contact belonging to `account_id` from `backend`, persisting
|
||||
/// each via [`DatabaseEngine::save_contact`]. Returns the number of contacts
|
||||
/// processed.
|
||||
///
|
||||
/// Pure fetch-and-persist, unit-testable directly with a
|
||||
/// [`nuncio_contacts::MockContactsBackend`] -- no event-bus or credential
|
||||
/// logic, mirroring `nunciod::calendar_sync::sync_with_backend`.
|
||||
pub async fn sync_with_backend(
|
||||
db: &nuncio_store::db::DatabaseEngine,
|
||||
backend: &dyn ContactsBackend,
|
||||
account_id: &str,
|
||||
) -> Result<usize, ContactsSyncError> {
|
||||
let contacts = backend.fetch_contacts(account_id).await?;
|
||||
let mut synced = 0usize;
|
||||
for contact in contacts {
|
||||
db.save_contact(&contact).await?;
|
||||
synced += 1;
|
||||
}
|
||||
Ok(synced)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nuncio_contacts::{Contact, MockContactsBackend};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
|
||||
fn mock_contact(id: &str, account_id: &str, display_name: &str) -> Contact {
|
||||
let mut contact = Contact::new(display_name, format!("{display_name}@nuncio.mx"));
|
||||
contact.id = id.to_string();
|
||||
contact.account_id = Some(account_id.to_string());
|
||||
contact
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_with_backend_persists_mock_contacts() {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("ephemeral db");
|
||||
|
||||
let mock = MockContactsBackend::new();
|
||||
mock.add_contact(mock_contact("ct-1", "acct-contacts-1", "Alice"));
|
||||
mock.add_contact(mock_contact("ct-2", "acct-contacts-1", "Bob"));
|
||||
// Belongs to a different account -- must not be returned or persisted
|
||||
// for `acct-contacts-1`'s sync.
|
||||
mock.add_contact(mock_contact("ct-other", "acct-other", "Carol"));
|
||||
|
||||
let synced = sync_with_backend(&db, &mock, "acct-contacts-1")
|
||||
.await
|
||||
.expect("sync succeeds");
|
||||
assert_eq!(synced, 2);
|
||||
|
||||
let persisted = db
|
||||
.list_contacts("acct-contacts-1")
|
||||
.await
|
||||
.expect("list persisted contacts");
|
||||
assert_eq!(persisted.len(), 2);
|
||||
let ids: Vec<&str> = persisted.iter().map(|c| c.id.as_str()).collect();
|
||||
assert!(ids.contains(&"ct-1"));
|
||||
assert!(ids.contains(&"ct-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 = MockContactsBackend::new();
|
||||
|
||||
let synced = sync_with_backend(&db, &mock, "acct-empty")
|
||||
.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 = MockContactsBackend::new();
|
||||
mock.set_should_fail(true);
|
||||
|
||||
let err = sync_with_backend(&db, &mock, "acct-contacts-1")
|
||||
.await
|
||||
.expect_err("sync fails");
|
||||
assert!(matches!(err, ContactsSyncError::Backend(_)));
|
||||
assert!(err.to_string().contains("contacts backend error"));
|
||||
}
|
||||
}
|
||||
+1260
-24
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,8 @@
|
||||
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
pub mod calendar_sync;
|
||||
pub mod contacts_sync;
|
||||
pub mod grpc;
|
||||
pub mod orchestrator;
|
||||
pub mod send;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! 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, ContactsEngineOverrides,
|
||||
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,
|
||||
ContactsEngineOverrides::default(),
|
||||
)
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Full-daemon offline end-to-end test for the contacts 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_contacts::MockContactsBackend`] standing in for real
|
||||
//! network I/O (via the [`nunciod::grpc::ContactsEngineOverrides`] injection
|
||||
//! seam).
|
||||
//!
|
||||
//! Driving ONLY the authenticated `nuncio-proto` gRPC client
|
||||
//! (`connect_contacts`) with the shared bearer token -- never reaching into
|
||||
//! daemon internals -- this proves:
|
||||
//! a. `Contacts/Sync` fetches from the injected mock backend and persists
|
||||
//! the result, awaiting full completion before returning, so the
|
||||
//! synced contacts are immediately visible to `ListContacts`/
|
||||
//! `GetContact`.
|
||||
//! b. `Contacts/CreateContact` persists a locally-authored contact
|
||||
//! directly to the daemon's real store, and it is visible via a
|
||||
//! SECOND, SEPARATE client connection against the same daemon --
|
||||
//! proving real cross-process-style persistence, not just
|
||||
//! within-process state.
|
||||
//! c. An unauthenticated call to `Contacts` 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_contacts::{Contact, MockContactsBackend};
|
||||
use nuncio_core::EventBus;
|
||||
use nuncio_filter::FilterEngine;
|
||||
use nuncio_proto::v1::{
|
||||
ContactEmail as ContactEmailProto, ContactsSyncRequest, CreateContactRequest,
|
||||
GetContactRequest, ListContactsRequest,
|
||||
};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tonic::Code;
|
||||
|
||||
const ACCOUNT_ID: &str = "acct-contacts-e2e-1";
|
||||
|
||||
fn mock_contact(id: &str, display_name: &str) -> Contact {
|
||||
let mut contact = Contact::new(display_name, format!("{display_name}@nuncio.mx"));
|
||||
contact.id = id.to_string();
|
||||
contact.account_id = Some(ACCOUNT_ID.to_string());
|
||||
contact
|
||||
}
|
||||
|
||||
/// Boots the real daemon gRPC server, injecting `contacts_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>,
|
||||
contacts_overrides: ContactsEngineOverrides,
|
||||
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
|
||||
let db_path = dir.path().join("contacts_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(),
|
||||
CalendarEngineOverrides::default(),
|
||||
contacts_overrides,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
(addr, token, dir)
|
||||
}
|
||||
|
||||
/// The full offline, deterministic end-to-end proof of the contacts
|
||||
/// vertical: `Sync` against an injected mock backend, then `ListContacts`/
|
||||
/// `GetContact` immediately reflect the persisted result; `CreateContact`
|
||||
/// persists a locally-authored contact that a SECOND, independent client
|
||||
/// connection can then read back -- ALL over the authenticated
|
||||
/// `nuncio.v1.Contacts` gRPC API against a live in-process daemon, with
|
||||
/// protocol I/O fully mocked.
|
||||
#[tokio::test]
|
||||
async fn contacts_sync_create_and_list_round_trip_over_authenticated_grpc() {
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
|
||||
let mock_backend = MockContactsBackend::new();
|
||||
mock_backend.add_contact(mock_contact("ct-e2e-1", "E2E Contact One"));
|
||||
mock_backend.add_contact(mock_contact("ct-e2e-2", "E2E Contact Two"));
|
||||
|
||||
let contacts_overrides = ContactsEngineOverrides {
|
||||
contacts_backend: Some(Arc::new(mock_backend)),
|
||||
};
|
||||
|
||||
let (addr, token, _db_dir) = boot_daemon(secrets, contacts_overrides).await;
|
||||
let addr_str = addr.to_string();
|
||||
|
||||
let mut contacts_client = nuncio_proto::client::connect_contacts(&addr_str, &token)
|
||||
.await
|
||||
.expect("contacts client connects");
|
||||
|
||||
// ---- (a) Sync fetches from the injected mock backend and persists ----
|
||||
let sync_response = contacts_client
|
||||
.sync(ContactsSyncRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.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 = contacts_client
|
||||
.list_contacts(ListContactsRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("list_contacts succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(list_response.contacts.len(), 2);
|
||||
let synced_ids: Vec<&str> = list_response
|
||||
.contacts
|
||||
.iter()
|
||||
.map(|c| c.id.as_str())
|
||||
.collect();
|
||||
assert!(synced_ids.contains(&"ct-e2e-1"));
|
||||
assert!(synced_ids.contains(&"ct-e2e-2"));
|
||||
|
||||
let get_response = contacts_client
|
||||
.get_contact(GetContactRequest {
|
||||
contact_id: "ct-e2e-1".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("get_contact succeeds")
|
||||
.into_inner();
|
||||
let contact = get_response.contact.expect("contact present in response");
|
||||
assert_eq!(contact.display_name, "E2E Contact One");
|
||||
|
||||
// ---- (b) CreateContact persists real local state, visible via a
|
||||
// SECOND, independent client connection ----
|
||||
let create_response = contacts_client
|
||||
.create_contact(CreateContactRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
display_name: "CLI Added Contact".to_string(),
|
||||
organization: Some("Kof22".to_string()),
|
||||
emails: vec![ContactEmailProto {
|
||||
email: "cli.added@kof22.com".to_string(),
|
||||
label: "work".to_string(),
|
||||
is_primary: true,
|
||||
}],
|
||||
phones: vec![],
|
||||
})
|
||||
.await
|
||||
.expect("create_contact succeeds")
|
||||
.into_inner();
|
||||
let created_id = create_response
|
||||
.contact
|
||||
.expect("created contact present in response")
|
||||
.id;
|
||||
|
||||
// A brand-new client connection (simulating a second, separate CLI
|
||||
// invocation against the same running daemon) sees the created contact
|
||||
// -- proving real persistence through the daemon's own store, not
|
||||
// process-local state.
|
||||
let mut second_client = nuncio_proto::client::connect_contacts(&addr_str, &token)
|
||||
.await
|
||||
.expect("second contacts client connects");
|
||||
let second_list = second_client
|
||||
.list_contacts(ListContactsRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("list_contacts succeeds on second connection")
|
||||
.into_inner();
|
||||
assert_eq!(second_list.contacts.len(), 3);
|
||||
let second_ids: Vec<&str> = second_list.contacts.iter().map(|c| c.id.as_str()).collect();
|
||||
assert!(second_ids.contains(&created_id.as_str()));
|
||||
|
||||
let second_get = second_client
|
||||
.get_contact(GetContactRequest {
|
||||
contact_id: created_id,
|
||||
})
|
||||
.await
|
||||
.expect("get_contact succeeds on second connection")
|
||||
.into_inner();
|
||||
let created_contact = second_get.contact.expect("contact present in response");
|
||||
assert_eq!(created_contact.display_name, "CLI Added Contact");
|
||||
assert_eq!(created_contact.organization.as_deref(), Some("Kof22"));
|
||||
|
||||
// ---- (c) An unauthenticated call is rejected ----
|
||||
// Uses the raw generated client (bypassing `connect_contacts`'s
|
||||
// bearer-token interceptor) to prove `Contacts` is mounted behind its
|
||||
// own `BearerAuthInterceptor`, exactly like every other service.
|
||||
let mut unauthenticated_client =
|
||||
nuncio_proto::v1::contacts_client::ContactsClient::connect(format!("http://{addr_str}"))
|
||||
.await
|
||||
.expect("client connects at the transport level without any auth");
|
||||
let err = unauthenticated_client
|
||||
.list_contacts(ListContactsRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
@@ -42,7 +42,10 @@ 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, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
@@ -125,6 +128,8 @@ async fn boot_daemon(
|
||||
server_secrets,
|
||||
server_token,
|
||||
overrides,
|
||||
CalendarEngineOverrides::default(),
|
||||
ContactsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user