diff --git a/Cargo.lock b/Cargo.lock index 0a4dd07..19a0b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2513,6 +2513,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing", + "wiremock", ] [[package]] @@ -2611,6 +2612,7 @@ dependencies = [ "hex", "hmac", "keyring", + "nuncio-contacts", "nuncio-core", "nuncio-filter", "serde", diff --git a/crates/nuncio-contacts/Cargo.toml b/crates/nuncio-contacts/Cargo.toml index 2cc1465..c91f4c9 100644 --- a/crates/nuncio-contacts/Cargo.toml +++ b/crates/nuncio-contacts/Cargo.toml @@ -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 } diff --git a/crates/nuncio-contacts/src/backend.rs b/crates/nuncio-contacts/src/backend.rs new file mode 100644 index 0000000..75a9af3 --- /dev/null +++ b/crates/nuncio-contacts/src/backend.rs @@ -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, CardDavError>; +} diff --git a/crates/nuncio-contacts/src/carddav.rs b/crates/nuncio-contacts/src/carddav.rs index f150f21..0fe496b 100644 --- a/crates/nuncio-contacts/src/carddav.rs +++ b/crates/nuncio-contacts/src/carddav.rs @@ -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, 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 `` 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#" + + + + + +"# + .to_string() + } + + /// Parse a CardDAV WebDAV XML `` response containing embedded vCard data. + pub fn parse_multistatus_response( + &self, + account_id: &str, + raw_xml: &str, + ) -> Result, CardDavError> { + let mut contacts = Vec::new(); + + for block in raw_xml.split("") { + if let Some((vcard_data, _)) = block.split_once("") { + 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, 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, 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(" + + + /addressbooks/contacts/alice.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Alice Dev +EMAIL:alice@nuncio.mx +END:VCARD + + + + "#; + + 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#" + "#; + + let contacts = client + .parse_multistatus_response("acct-1", xml_response) + .expect("parse succeeds"); + assert!(contacts.is_empty()); } } diff --git a/crates/nuncio-contacts/src/lib.rs b/crates/nuncio-contacts/src/lib.rs index f935b72..3911bfc 100644 --- a/crates/nuncio-contacts/src/lib.rs +++ b/crates/nuncio-contacts/src/lib.rs @@ -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 { diff --git a/crates/nuncio-contacts/src/mock.rs b/crates/nuncio-contacts/src/mock.rs new file mode 100644 index 0000000..8e07537 --- /dev/null +++ b/crates/nuncio-contacts/src/mock.rs @@ -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>>, + should_fail: Arc>, +} + +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, 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, 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 = 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"); + } +} diff --git a/crates/nuncio-contacts/src/parser.rs b/crates/nuncio-contacts/src/parser.rs new file mode 100644 index 0000000..ca83878 --- /dev/null +++ b/crates/nuncio-contacts/src/parser.rs @@ -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 { + 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 = None; + let mut given_name: Option = None; + let mut family_name: Option = None; + let mut organization: Option = None; + let mut job_title: Option = None; + let mut emails: Vec = Vec::new(); + let mut phones: Vec = 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 { + 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); + } +} diff --git a/crates/nuncio-contacts/tests/wiremock_carddav_test.rs b/crates/nuncio-contacts/tests/wiremock_carddav_test.rs new file mode 100644 index 0000000..a9c3ee4 --- /dev/null +++ b/crates/nuncio-contacts/tests/wiremock_carddav_test.rs @@ -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#" + + + /addressbooks/contacts/james.vcf + + + 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 + + + + + /addressbooks/contacts/alice.vcf + + + BEGIN:VCARD +VERSION:4.0 +FN:Alice Dev +EMAIL:alice@nuncio.mx +END:VCARD + + + +"# + .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#" + + + /addressbooks/malformed/ghost.vcf + + + BEGIN:VCARD +VERSION:4.0 +ORG:No Name Or Email +END:VCARD + + + +"#; + + 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(_))); +} diff --git a/crates/nuncio-store/Cargo.toml b/crates/nuncio-store/Cargo.toml index 347f5c9..d60e957 100644 --- a/crates/nuncio-store/Cargo.toml +++ b/crates/nuncio-store/Cargo.toml @@ -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 diff --git a/crates/nuncio-store/src/db.rs b/crates/nuncio-store/src/db.rs index 79066c2..50fd1cc 100644 --- a/crates/nuncio-store/src/db.rs +++ b/crates/nuncio-store/src/db.rs @@ -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 = + serde_json::from_str(&emails_json).unwrap_or_default(); + let phones: Vec = + 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 = 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::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) @@ -1034,6 +1128,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, 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 { + 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 @@ -2026,6 +2217,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,