From abf6c9fb1191f8c592595d651803f44407b9be34 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 29 Jul 2026 18:14:17 -0500 Subject: [PATCH] feat(contacts): add Contacts gRPC vertical (proto, daemon, CLI, E2E) Publish the M2-S1 contacts engine over the daemon's authenticated gRPC API, mirroring the Calendar vertical: add nuncio.v1.Contacts (Sync, ListContacts, GetContact, CreateContact) plus Contact/ContactEmail/ ContactPhone messages to the proto contract and regenerate the golden descriptor. Wire ContactsEngineOverrides + ContactsGrpcService into nunciod, mounted behind its own BearerAuthInterceptor exactly like every other service. ListContacts/GetContact always read genuinely persisted data from DatabaseEngine; Sync fetches from an injected ContactsBackend (MockContactsBackend in tests) and persists via contacts_sync::sync_with_backend, mirroring calendar_sync.rs; with no backend injected, production Sync returns an honest error since AccountConfig carries no per-account CardDAV configuration yet. Add connect_contacts/AuthenticatedContactsClient to nuncio-proto's shared client helpers. Replace the CLI's Commands::Contact handlers (previously a disposable in-memory ContactsDatabase.in_memory() per invocation) with real gRPC calls against the daemon, so a contact added via `contact add` persists and is visible to a later, separate CLI invocation. Deviations from the issue's literal proto sketch, flagged for review: - Added CreateContact (not in the issue's message list): the acceptance criteria requires `contact add` to persist a locally- authored contact across CLI invocations, but the daemon has no per-account CardDAV credential to write back to a real server. This RPC only ever writes to the daemon's own local store -- never a fabricated CardDAV write-back -- which is the same honest-surface spirit as Calendar's "no create/delete" note (that note is about not pretending to write to the remote server, not about never persisting locally-authored data at all). - ListContacts/CreateContact take a required account_id (the issue sketched a bare ListContactsRequest{}), because the real DatabaseEngine::list_contacts/save_contact are account-scoped; ContactSubcommand::List/Search/Add/Sync now all take --account to match. - Contact search has no server-side RPC; `contact search` fetches the full ListContacts result and filters client-side by display name, organization, and email. - Contact's created_at/updated_at/last_interacted_at are omitted from the proto message -- no client use case identified for them yet. Full-daemon offline E2E (contacts_e2e_test.rs) proves Sync -> List/Get over the authenticated API, CreateContact's persistence surviving a second, independent client connection, and an unauthenticated call rejected with Code::Unauthenticated. --- Cargo.lock | 2 +- crates/nuncio-cli/Cargo.toml | 1 - crates/nuncio-cli/src/args.rs | 32 +- crates/nuncio-cli/src/runner.rs | 539 ++++++++++++++++-- .../proto/nuncio/v1/descriptor.bin | Bin 8772 -> 10546 bytes .../nuncio-proto/proto/nuncio/v1/nuncio.proto | 146 +++++ crates/nuncio-proto/src/client.rs | 43 ++ crates/nunciod/Cargo.toml | 1 + crates/nunciod/src/contacts_sync.rs | 123 ++++ crates/nunciod/src/grpc.rs | 536 ++++++++++++++++- crates/nunciod/src/lib.rs | 1 + crates/nunciod/tests/calendar_e2e_test.rs | 4 +- crates/nunciod/tests/contacts_e2e_test.rs | 237 ++++++++ crates/nunciod/tests/spine_e2e_test.rs | 4 +- 14 files changed, 1580 insertions(+), 89 deletions(-) create mode 100644 crates/nunciod/src/contacts_sync.rs create mode 100644 crates/nunciod/tests/contacts_e2e_test.rs diff --git a/Cargo.lock b/Cargo.lock index 19a0b75..f4191a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2478,7 +2478,6 @@ dependencies = [ "clap", "hex", "nuncio-cal", - "nuncio-contacts", "nuncio-core", "nuncio-filter", "nuncio-mail", @@ -2636,6 +2635,7 @@ dependencies = [ "hex", "nuncio-cal", "nuncio-cli", + "nuncio-contacts", "nuncio-core", "nuncio-filter", "nuncio-mail", diff --git a/crates/nuncio-cli/Cargo.toml b/crates/nuncio-cli/Cargo.toml index 8ebdfbc..f680cdc 100644 --- a/crates/nuncio-cli/Cargo.toml +++ b/crates/nuncio-cli/Cargo.toml @@ -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 diff --git a/crates/nuncio-cli/src/args.rs b/crates/nuncio-cli/src/args.rs index 8cb0153..db359e8 100644 --- a/crates/nuncio-cli/src/args.rs +++ b/crates/nuncio-cli/src/args.rs @@ -135,19 +135,38 @@ pub enum Commands { } /// Contact subcommands (`nuncio contact `). +/// +/// 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, }, + /// 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 `). diff --git a/crates/nuncio-cli/src/runner.rs b/crates/nuncio-cli/src/runner.rs index 1339c18..6b10426 100644 --- a/crates/nuncio-cli/src/runner.rs +++ b/crates/nuncio-cli/src/runner.rs @@ -73,6 +73,50 @@ fn calendar_event_proto_to_json(event: &nuncio_proto::v1::CalendarEvent) -> serd }) } +/// 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::>(), + "phones": contact.phones.iter().map(|p| json!({ + "phone": p.phone, + "label": p.label, + "is_primary": p.is_primary, + })).collect::>(), + "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. @@ -393,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 { @@ -1713,6 +1713,197 @@ impl HeadlessRunner { } } + /// 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 { + 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 = + 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 = + 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 { @@ -2706,6 +2897,236 @@ mod tests { 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>>, + last_sync: Arc>>, + last_create: Arc>>, + } + + #[tonic::async_trait] + impl ContactsService for StubContacts { + async fn sync( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let req = request.into_inner(); + *self.last_sync.lock().unwrap_or_else(|e| e.into_inner()) = Some(req); + Ok(tonic::Response::new(ContactsSyncResponse { + synced_count: 2, + })) + } + + async fn list_contacts( + &self, + request: tonic::Request, + ) -> Result, 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, + ) -> Result, 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, + ) -> Result, 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 diff --git a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin index 5a4b5ad575acfc32c30137c1e29cb8ad29503650..0494ff37b592e2c4f651ed9872906b6311564d68 100644 GIT binary patch delta 1580 zcmb7E%Wl&^6eZ8bH%_4@G%g}EwGgcY1gKzv!~#d zBfeFI0u~>3rjc1OkU9v|_x7-FXB8?BRvmy)w;Pd;7aiF-9x5S&&>aOhqtZLC!T^gR zUGXt2Ee?AU{{PxP@-U;{ZOuZK?CYZZ%W?p`c023^%;k7?@G)17ttBw`3GMpcksEj& zEMIG&vc{fpNaP)oJ`NNiFFM`IAVXq;@Gg_Fu0Z*~>kwa8DM(TlfdYrDWiZ3&&880G3Vb3F9aBf18>lqWD+R(_Ylviubpck}B*zDhKrjL%lI?!bB7KHWVE+Jlq%tEO`g zszY!<=WkUw=`o>9VT0rOmDt&U>Ih;KVcHD?imfJ;+G>#~_oA4zH92XNaGX_`+r+vu zL-@03F5X`2YX_ delta 21 dcmdlKbi{?3>!8a<=1=0A=gKbPoZPCh764&^2;l$# diff --git a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto index ddd12ec..a4027ec 100644 --- a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto +++ b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto @@ -735,6 +735,152 @@ 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. diff --git a/crates/nuncio-proto/src/client.rs b/crates/nuncio-proto/src/client.rs index 1f13c10..f1944d5 100644 --- a/crates/nuncio-proto/src/client.rs +++ b/crates/nuncio-proto/src/client.rs @@ -12,6 +12,7 @@ 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; @@ -118,6 +119,11 @@ pub type AuthenticatedAuditClient = pub type AuthenticatedCalendarClient = CalendarClient>; +/// The authenticated `nuncio.v1.Contacts` client type returned by +/// [`connect_contacts`]. +pub type AuthenticatedContactsClient = + ContactsClient>; + /// 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 @@ -264,6 +270,26 @@ pub async fn connect_calendar( Ok(CalendarClient::with_interceptor(channel, interceptor)) } +/// Dials the `nuncio.v1.Contacts` gRPC endpoint at `addr` and returns a +/// client that injects `authorization: Bearer ` 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 { + 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 @@ -439,6 +465,23 @@ mod tests { 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 diff --git a/crates/nunciod/Cargo.toml b/crates/nunciod/Cargo.toml index 21757cc..8a47d69 100644 --- a/crates/nunciod/Cargo.toml +++ b/crates/nunciod/Cargo.toml @@ -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" } diff --git a/crates/nunciod/src/contacts_sync.rs b/crates/nunciod/src/contacts_sync.rs new file mode 100644 index 0000000..b5cb883 --- /dev/null +++ b/crates/nunciod/src/contacts_sync.rs @@ -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 { + 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")); + } +} diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index a33c0f7..487c286 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -6,12 +6,14 @@ //! This is the daemon's sole client-facing transport. use nuncio_cal::CalendarBackend; +use nuncio_contacts::ContactsBackend; use nuncio_core::{CoreCommand, CoreEvent, EventBus}; use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions}; use nuncio_mail::{MailBackend, MessageSender}; use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer}; use nuncio_proto::v1::audit_server::{Audit, AuditServer}; use nuncio_proto::v1::calendar_server::{Calendar, CalendarServer}; +use nuncio_proto::v1::contacts_server::{Contacts, ContactsServer}; use nuncio_proto::v1::event::Kind; use nuncio_proto::v1::export_server::{Export, ExportServer}; use nuncio_proto::v1::filters_server::{Filters, FiltersServer}; @@ -21,19 +23,22 @@ use nuncio_proto::v1::{ export_request, AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto, AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto, AuditRecord as AuditRecordProto, BatchFilterProgress, CalendarEvent as CalendarEventProto, - CalendarSyncRequest, CalendarSyncResponse, CreateRuleRequest, CreateRuleResponse, - DatabaseRecovered, DeleteRuleRequest, DeleteRuleResponse, Event, EventError, - ExportFormat as ExportFormatProto, ExportRequest, ExportResponse, FilterExecuted, - FilterRule as FilterRuleProto, Folder as FolderProto, GetEventRequest, GetEventResponse, - GetMessageRequest, GetMessageResponse, GetStatusRequest, GetStatusResponse, - ListAccountsRequest, ListAccountsResponse, ListEventsRequest, ListEventsResponse, - ListFoldersRequest, ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, - ListRecordsRequest, ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest, - MarkReadResponse, Message as MessageProto, MessageFlagsChanged, MessageSearchHit, - PreviewRuleRequest, PreviewRuleResponse, SearchMessagesRequest, SearchMessagesResponse, - SendMessageRequest, SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted, - SyncRequest, SyncResponse, SyncStarted, TlsMode as TlsModeProto, UpdateAvailable, - ValidateRuleRequest, ValidateRuleResponse, VerifyChainRequest, VerifyChainResponse, + CalendarSyncRequest, CalendarSyncResponse, Contact as ContactProto, + ContactEmail as ContactEmailProto, ContactPhone as ContactPhoneProto, ContactsSyncRequest, + ContactsSyncResponse, CreateContactRequest, CreateContactResponse, CreateRuleRequest, + CreateRuleResponse, DatabaseRecovered, DeleteRuleRequest, DeleteRuleResponse, Event, + EventError, ExportFormat as ExportFormatProto, ExportRequest, ExportResponse, FilterExecuted, + FilterRule as FilterRuleProto, Folder as FolderProto, GetContactRequest, GetContactResponse, + GetEventRequest, GetEventResponse, GetMessageRequest, GetMessageResponse, GetStatusRequest, + GetStatusResponse, ListAccountsRequest, ListAccountsResponse, ListContactsRequest, + ListContactsResponse, ListEventsRequest, ListEventsResponse, ListFoldersRequest, + ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, ListRecordsRequest, + ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest, MarkReadResponse, + Message as MessageProto, MessageFlagsChanged, MessageSearchHit, PreviewRuleRequest, + PreviewRuleResponse, SearchMessagesRequest, SearchMessagesResponse, SendMessageRequest, + SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, + SyncStarted, TlsMode as TlsModeProto, UpdateAvailable, ValidateRuleRequest, + ValidateRuleResponse, VerifyChainRequest, VerifyChainResponse, }; use nuncio_store::db::DatabaseEngine; use nuncio_store::search::SearchEngine; @@ -439,6 +444,45 @@ fn map_calendar_event_to_proto(event: nuncio_core::model::CalendarEvent) -> Cale } } +/// Maps a `nuncio_contacts::Contact` onto its wire-format `nuncio.v1.Contact` +/// representation. The internal bookkeeping timestamps +/// (`created_at`/`updated_at`/`last_interacted_at`) are deliberately not +/// carried over -- see the `Contact` message's doc comment in the `.proto` +/// source. +fn map_contact_to_proto(contact: nuncio_contacts::Contact) -> ContactProto { + ContactProto { + 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, + notes: contact.notes, + avatar_url: contact.avatar_url, + emails: contact + .emails + .into_iter() + .map(|e| ContactEmailProto { + email: e.email, + label: e.label, + is_primary: e.is_primary, + }) + .collect(), + phones: contact + .phones + .into_iter() + .map(|p| ContactPhoneProto { + phone: p.phone, + label: p.label, + is_primary: p.is_primary, + }) + .collect(), + is_favorite: contact.is_favorite, + interaction_count: contact.interaction_count, + } +} + /// Combines an account's in-window events with its recurring masters into the final event /// list for a `ListEvents` query window, expanding each recurring master into its real /// occurrences via [`nuncio_cal::RecurrenceEngine::expand_occurrences`] rather than trusting @@ -605,6 +649,168 @@ impl Calendar for CalendarGrpcService { } } +/// Test-only injection point for the daemon's real per-account CardDAV +/// contacts backend, mirroring [`CalendarEngineOverrides`] exactly. +/// +/// Production (every existing caller of [`serve`] / [`serve_on_listener`]) +/// never constructs a non-default instance: [`Default`] yields `None`, +/// which routes `Contacts/Sync` to an honest "no CardDAV configuration" +/// error, since `nuncio_core::AccountConfig` has no CardDAV collection +/// URL/credential to build a real per-account `CardDavClient` from yet. Only +/// a full-daemon E2E test supplies `Some(..)`, standing in a +/// [`nuncio_contacts::MockContactsBackend`] for real network I/O. +#[derive(Clone, Default)] +pub struct ContactsEngineOverrides { + /// When `Some`, `Contacts/Sync` fetches from this backend instead of + /// returning an honest "no CardDAV configuration" error. + pub contacts_backend: Option>, +} + +impl std::fmt::Debug for ContactsEngineOverrides { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ContactsEngineOverrides") + .field("contacts_backend", &self.contacts_backend.is_some()) + .finish() + } +} + +/// `nuncio.v1.Contacts` gRPC service implementation backed by the daemon's +/// live [`DatabaseEngine`] contact read/write methods. +/// +/// `ListContacts`/`GetContact` always read genuinely persisted data. +/// `CreateContact` persists a locally-authored contact directly to the +/// store -- real local persistence, never a fabricated CardDAV write-back. +/// `Sync` fetches from [`ContactsEngineOverrides::contacts_backend`] when +/// injected; otherwise it returns an honest error, since there is no +/// persisted per-account CardDAV configuration to build a real backend from +/// yet (see `nunciod::contacts_sync`'s doc comment). +struct ContactsGrpcService { + db: Arc, + overrides: ContactsEngineOverrides, +} + +#[tonic::async_trait] +impl Contacts for ContactsGrpcService { + async fn sync( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let synced = match &self.overrides.contacts_backend { + Some(backend) => { + crate::contacts_sync::sync_with_backend(&self.db, backend.as_ref(), &req.account_id) + .await + .map_err(|e| Status::internal(format!("contacts sync failed: {e}")))? + } + None => { + return Err(Status::internal(format!( + "no CardDAV configuration exists for account '{}'; per-account CardDAV \ + configuration is not yet implemented", + req.account_id + ))); + } + }; + + Ok(Response::new(ContactsSyncResponse { + synced_count: synced as u64, + })) + } + + async fn list_contacts( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.account_id.is_empty() { + return Err(Status::invalid_argument("account_id is required")); + } + + let contacts = self + .db + .list_contacts(&req.account_id) + .await + .map_err(|e| Status::internal(format!("failed to list contacts: {e}")))? + .into_iter() + .map(map_contact_to_proto) + .collect(); + + Ok(Response::new(ListContactsResponse { contacts })) + } + + async fn get_contact( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.contact_id.is_empty() { + return Err(Status::invalid_argument("contact_id is required")); + } + + let contact = self.db.get_contact(&req.contact_id).await.map_err(|e| { + Status::not_found(format!("contact '{}' not found: {e}", req.contact_id)) + })?; + + Ok(Response::new(GetContactResponse { + contact: Some(map_contact_to_proto(contact)), + })) + } + + async fn create_contact( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.account_id.is_empty() { + return Err(Status::invalid_argument("account_id is required")); + } + if req.display_name.is_empty() { + return Err(Status::invalid_argument("display_name is required")); + } + + let primary_email = req + .emails + .iter() + .find(|e| e.is_primary) + .or_else(|| req.emails.first()) + .map(|e| e.email.clone()) + .unwrap_or_default(); + + let mut contact = nuncio_contacts::Contact::new(req.display_name, primary_email); + contact.account_id = Some(req.account_id); + contact.organization = req.organization; + if !req.emails.is_empty() { + contact.emails = req + .emails + .into_iter() + .map(|e| nuncio_contacts::ContactEmail { + email: e.email, + label: e.label, + is_primary: e.is_primary, + }) + .collect(); + } + contact.phones = req + .phones + .into_iter() + .map(|p| nuncio_contacts::ContactPhone { + phone: p.phone, + label: p.label, + is_primary: p.is_primary, + }) + .collect(); + + self.db + .save_contact(&contact) + .await + .map_err(|e| Status::internal(format!("failed to save contact: {e}")))?; + + Ok(Response::new(CreateContactResponse { + contact: Some(map_contact_to_proto(contact)), + })) + } +} + /// Test-only injection point for the daemon's inbound sync and outbound /// send engines, threaded through the daemon's runtime wiring for the /// full-daemon offline spine E2E test. @@ -1316,8 +1522,9 @@ pub async fn serve( } /// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, `nuncio.v1.Mail`, -/// `nuncio.v1.Filters`, `nuncio.v1.Export`, `nuncio.v1.Audit`, and -/// `nuncio.v1.Calendar` gRPC services on an already-bound [`TcpListener`]. +/// `nuncio.v1.Filters`, `nuncio.v1.Export`, `nuncio.v1.Audit`, +/// `nuncio.v1.Calendar`, and `nuncio.v1.Contacts` gRPC services on an +/// already-bound [`TcpListener`]. /// /// # Security /// @@ -1350,15 +1557,18 @@ pub async fn serve_on_listener( token, MailEngineOverrides::default(), CalendarEngineOverrides::default(), + ContactsEngineOverrides::default(), ) .await } /// Identical to [`serve_on_listener`], except the `nuncio.v1.Mail` service's -/// inbound sync and outbound send engines, and the `nuncio.v1.Calendar` -/// service's sync backend, can be overridden. +/// inbound sync and outbound send engines, the `nuncio.v1.Calendar` +/// service's sync backend, and the `nuncio.v1.Contacts` service's sync +/// backend can be overridden. /// [`serve_on_listener`] is simply this function called with -/// `MailEngineOverrides::default()` / `CalendarEngineOverrides::default()` +/// `MailEngineOverrides::default()` / `CalendarEngineOverrides::default()` / +/// `ContactsEngineOverrides::default()` /// (i.e. every field `None`, which is production's exact prior behavior); /// this function exists so a full-daemon offline E2E test can supply /// `Some(..)` without changing [`serve_on_listener`]'s signature for its many @@ -1373,6 +1583,7 @@ pub async fn serve_on_listener_with_overrides( token: impl Into>, overrides: MailEngineOverrides, calendar_overrides: CalendarEngineOverrides, + contacts_overrides: ContactsEngineOverrides, ) -> Result<(), GrpcServeError> { let token: Arc = token.into(); @@ -1429,12 +1640,22 @@ pub async fn serve_on_listener_with_overrides( // every other service above -- see the hard invariant documented on // this function's doc comment. let calendar_service = CalendarGrpcService { - db, + db: db.clone(), overrides: calendar_overrides, }; - let calendar_interceptor = BearerAuthInterceptor::new(token); + let calendar_interceptor = BearerAuthInterceptor::new(token.clone()); let calendar_svc = CalendarServer::with_interceptor(calendar_service, calendar_interceptor); + // Contacts: mounted behind its own `BearerAuthInterceptor`, exactly like + // every other service above -- see the hard invariant documented on + // this function's doc comment. + let contacts_service = ContactsGrpcService { + db, + overrides: contacts_overrides, + }; + let contacts_interceptor = BearerAuthInterceptor::new(token); + let contacts_svc = ContactsServer::with_interceptor(contacts_service, contacts_interceptor); + Server::builder() .add_service(system_svc) .add_service(accounts_svc) @@ -1443,6 +1664,7 @@ pub async fn serve_on_listener_with_overrides( .add_service(export_svc) .add_service(audit_svc) .add_service(calendar_svc) + .add_service(contacts_svc) .serve_with_incoming(TcpListenerStream::new(listener)) .await .map_err(GrpcServeError::Transport) @@ -1533,15 +1755,18 @@ mod tests { token, overrides, CalendarEngineOverrides::default(), + ContactsEngineOverrides::default(), ) .await } /// Spawns a test server on an ephemeral loopback port backed by the - /// given `db`/`filter_engine`/`secrets`/`overrides`/`calendar_overrides`: - /// the helper every test that injects a - /// [`CalendarEngineOverrides::calendar_backend`] uses to prove `Sync` + /// given `db`/`filter_engine`/`secrets`/`overrides`/`calendar_overrides`/ + /// `contacts_overrides`: the helper every test that injects a + /// [`CalendarEngineOverrides::calendar_backend`] / + /// [`ContactsEngineOverrides::contacts_backend`] uses to prove `Sync` /// drives an injected test double over the real authenticated gRPC API. + #[allow(clippy::too_many_arguments)] async fn spawn_test_server_with_all_overrides( event_bus: Arc, db: Arc, @@ -1550,6 +1775,7 @@ mod tests { token: &str, overrides: MailEngineOverrides, calendar_overrides: CalendarEngineOverrides, + contacts_overrides: ContactsEngineOverrides, ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0") .await @@ -1566,6 +1792,7 @@ mod tests { token, overrides, calendar_overrides, + contacts_overrides, ) .await; }); @@ -3967,6 +4194,7 @@ mod tests { "correct-token", MailEngineOverrides::default(), calendar_overrides, + ContactsEngineOverrides::default(), ) .await; @@ -4141,4 +4369,266 @@ mod tests { assert_eq!(result, vec![master]); } + + fn sample_contact(id: &str, account_id: &str, display_name: &str) -> nuncio_contacts::Contact { + let mut contact = + nuncio_contacts::Contact::new(display_name, format!("{display_name}@nuncio.mx")); + contact.id = id.to_string(); + contact.account_id = Some(account_id.to_string()); + contact + } + + /// Confirms `Contacts` is mounted behind its own `BearerAuthInterceptor` + /// exactly like every other service (no un-intercepted service). + #[tokio::test] + async fn contacts_rpcs_reject_missing_bearer_token() { + use nuncio_proto::v1::contacts_client::ContactsClient; + use nuncio_proto::v1::{ContactsSyncRequest, GetContactRequest, ListContactsRequest}; + + let event_bus = Arc::new(EventBus::new()); + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; + + let mut client = ContactsClient::connect(format!("http://{addr}")) + .await + .expect("client connects"); + + let err = client + .list_contacts(ListContactsRequest { + account_id: "acct-1".to_string(), + }) + .await + .expect_err("missing bearer token must be rejected"); + assert_eq!(err.code(), Code::Unauthenticated); + + let err = client + .get_contact(GetContactRequest { + contact_id: "ct-1".to_string(), + }) + .await + .expect_err("missing bearer token must be rejected"); + assert_eq!(err.code(), Code::Unauthenticated); + + let err = client + .sync(ContactsSyncRequest { + account_id: "acct-1".to_string(), + }) + .await + .expect_err("missing bearer token must be rejected"); + assert_eq!(err.code(), Code::Unauthenticated); + } + + #[tokio::test] + async fn list_contacts_returns_only_genuinely_persisted_contacts() { + use nuncio_proto::v1::contacts_client::ContactsClient; + use nuncio_proto::v1::ListContactsRequest; + + let (db, _dir) = DatabaseEngine::connect_ephemeral() + .await + .expect("connect ephemeral test db"); + db.save_contact(&sample_contact("ct-list-1", "acct-contacts-list", "Alice")) + .await + .expect("save contact"); + db.save_contact(&sample_contact("ct-list-2", "acct-contacts-list", "Bob")) + .await + .expect("save contact"); + db.save_contact(&sample_contact("ct-list-other", "acct-other", "Carol")) + .await + .expect("save contact"); + + let secrets = Arc::new(SecretManager::mock()); + let event_bus = Arc::new(EventBus::new()); + let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")); + let (addr, _handle) = spawn_test_server_with( + event_bus, + Arc::new(db), + filter_engine, + secrets, + "correct-token", + ) + .await; + + let mut client = ContactsClient::connect(format!("http://{addr}")) + .await + .expect("client connects"); + let response = client + .list_contacts(authed_bearer_request(ListContactsRequest { + account_id: "acct-contacts-list".to_string(), + })) + .await + .expect("list_contacts succeeds") + .into_inner(); + + assert_eq!(response.contacts.len(), 2); + let ids: Vec<&str> = response.contacts.iter().map(|c| c.id.as_str()).collect(); + assert!(ids.contains(&"ct-list-1")); + assert!(ids.contains(&"ct-list-2")); + assert!(!ids.contains(&"ct-list-other")); + } + + #[tokio::test] + async fn get_contact_reports_not_found_for_unknown_contact_id() { + use nuncio_proto::v1::contacts_client::ContactsClient; + use nuncio_proto::v1::GetContactRequest; + + let event_bus = Arc::new(EventBus::new()); + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; + + let mut client = ContactsClient::connect(format!("http://{addr}")) + .await + .expect("client connects"); + let err = client + .get_contact(authed_bearer_request(GetContactRequest { + contact_id: "does-not-exist".to_string(), + })) + .await + .expect_err("unknown contact id must be rejected"); + assert_eq!(err.code(), Code::NotFound); + } + + /// With no `ContactsEngineOverrides::contacts_backend` injected, `Sync` + /// returns an honest error -- there is no persisted per-account CardDAV + /// configuration to build a real backend from -- never a fabricated + /// `synced_count`. + #[tokio::test] + async fn contacts_sync_without_override_reports_honest_error() { + use nuncio_proto::v1::contacts_client::ContactsClient; + use nuncio_proto::v1::ContactsSyncRequest; + + let event_bus = Arc::new(EventBus::new()); + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; + + let mut client = ContactsClient::connect(format!("http://{addr}")) + .await + .expect("client connects"); + let err = client + .sync(authed_bearer_request(ContactsSyncRequest { + account_id: "acct-contacts-no-config".to_string(), + })) + .await + .expect_err("sync with no injected backend and no CardDAV config must fail"); + assert_eq!(err.code(), Code::Internal); + assert!(err.message().contains("acct-contacts-no-config")); + assert!(err.message().contains("no CardDAV configuration")); + } + + /// With a [`ContactsEngineOverrides::contacts_backend`] injected, `Sync` + /// genuinely fetches from it (via `contacts_sync::sync_with_backend`) + /// and awaits full completion before returning, so the synced contacts + /// are immediately visible to `ListContacts`/`GetContact` -- exactly the + /// mechanism a full-daemon offline E2E test uses. + #[tokio::test] + async fn contacts_sync_with_injected_backend_persists_and_is_immediately_visible() { + use nuncio_proto::v1::contacts_client::ContactsClient; + use nuncio_proto::v1::{ContactsSyncRequest, GetContactRequest, ListContactsRequest}; + + let (db, _dir) = DatabaseEngine::connect_ephemeral() + .await + .expect("connect ephemeral test db"); + let db = Arc::new(db); + let secrets = Arc::new(SecretManager::mock()); + + let mock_backend = nuncio_contacts::MockContactsBackend::new(); + mock_backend.add_contact(sample_contact( + "ct-sync-injected-1", + "acct-contacts-injected", + "Injected Contact", + )); + + let contacts_overrides = ContactsEngineOverrides { + contacts_backend: Some(Arc::new(mock_backend)), + }; + let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")); + let (addr, _handle) = spawn_test_server_with_all_overrides( + Arc::new(EventBus::new()), + db, + filter_engine, + secrets, + "correct-token", + MailEngineOverrides::default(), + CalendarEngineOverrides::default(), + contacts_overrides, + ) + .await; + + let mut client = ContactsClient::connect(format!("http://{addr}")) + .await + .expect("client connects"); + + let sync_response = client + .sync(authed_bearer_request(ContactsSyncRequest { + account_id: "acct-contacts-injected".to_string(), + })) + .await + .expect("sync succeeds") + .into_inner(); + assert_eq!(sync_response.synced_count, 1); + + let list_response = client + .list_contacts(authed_bearer_request(ListContactsRequest { + account_id: "acct-contacts-injected".to_string(), + })) + .await + .expect("list_contacts succeeds") + .into_inner(); + assert_eq!(list_response.contacts.len(), 1); + assert_eq!(list_response.contacts[0].id, "ct-sync-injected-1"); + + let get_response = client + .get_contact(authed_bearer_request(GetContactRequest { + contact_id: "ct-sync-injected-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, "Injected Contact"); + } + + #[tokio::test] + async fn create_contact_persists_and_is_immediately_visible() { + use nuncio_proto::v1::contacts_client::ContactsClient; + use nuncio_proto::v1::{ + ContactEmail as ContactEmailProto, CreateContactRequest, GetContactRequest, + }; + + let event_bus = Arc::new(EventBus::new()); + let (addr, _handle, _dir) = spawn_test_server(event_bus, "correct-token").await; + + let mut client = ContactsClient::connect(format!("http://{addr}")) + .await + .expect("client connects"); + + let create_response = client + .create_contact(authed_bearer_request(CreateContactRequest { + account_id: "acct-contacts-create".to_string(), + display_name: "New Contact".to_string(), + organization: Some("Kof22".to_string()), + emails: vec![ContactEmailProto { + email: "new.contact@kof22.com".to_string(), + label: "work".to_string(), + is_primary: true, + }], + phones: vec![], + })) + .await + .expect("create_contact succeeds") + .into_inner(); + let created = create_response + .contact + .expect("created contact present in response"); + assert_eq!(created.display_name, "New Contact"); + assert_eq!(created.account_id.as_deref(), Some("acct-contacts-create")); + assert_eq!(created.emails.len(), 1); + + let get_response = client + .get_contact(authed_bearer_request(GetContactRequest { + contact_id: created.id.clone(), + })) + .await + .expect("get_contact succeeds") + .into_inner(); + let fetched = get_response.contact.expect("contact present in response"); + assert_eq!(fetched.display_name, "New Contact"); + assert_eq!(fetched.organization.as_deref(), Some("Kof22")); + } } diff --git a/crates/nunciod/src/lib.rs b/crates/nunciod/src/lib.rs index 11bf971..7144aa8 100644 --- a/crates/nunciod/src/lib.rs +++ b/crates/nunciod/src/lib.rs @@ -3,6 +3,7 @@ #![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; diff --git a/crates/nunciod/tests/calendar_e2e_test.rs b/crates/nunciod/tests/calendar_e2e_test.rs index 98aaac7..d0e90b7 100644 --- a/crates/nunciod/tests/calendar_e2e_test.rs +++ b/crates/nunciod/tests/calendar_e2e_test.rs @@ -30,7 +30,8 @@ use nuncio_proto::v1::{CalendarSyncRequest, GetEventRequest, ListEventsRequest}; use nuncio_store::db::DatabaseEngine; use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT}; use nunciod::grpc::{ - serve_on_listener_with_overrides, CalendarEngineOverrides, MailEngineOverrides, + serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides, + MailEngineOverrides, }; use std::sync::Arc; use tokio::net::TcpListener; @@ -93,6 +94,7 @@ async fn boot_daemon( server_token, MailEngineOverrides::default(), calendar_overrides, + ContactsEngineOverrides::default(), ) .await; }); diff --git a/crates/nunciod/tests/contacts_e2e_test.rs b/crates/nunciod/tests/contacts_e2e_test.rs new file mode 100644 index 0000000..b5e9d8f --- /dev/null +++ b/crates/nunciod/tests/contacts_e2e_test.rs @@ -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, + 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); +} diff --git a/crates/nunciod/tests/spine_e2e_test.rs b/crates/nunciod/tests/spine_e2e_test.rs index ac7e161..27427c1 100644 --- a/crates/nunciod/tests/spine_e2e_test.rs +++ b/crates/nunciod/tests/spine_e2e_test.rs @@ -43,7 +43,8 @@ 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, CalendarEngineOverrides, MailEngineOverrides, + serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides, + MailEngineOverrides, }; use std::sync::Arc; use tokio::net::TcpListener; @@ -128,6 +129,7 @@ async fn boot_daemon( server_token, overrides, CalendarEngineOverrides::default(), + ContactsEngineOverrides::default(), ) .await; }); -- 2.54.0