feat(contacts): add Contacts gRPC vertical (proto, daemon, CLI, E2E) #218

Merged
KofTwentyTwo merged 1 commits from story/m2-contacts-api into dev 2026-07-29 23:23:32 +00:00
14 changed files with 1580 additions and 89 deletions

No files matched your search

Generated
+1 -1
View File
@@ -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",
-1
View File
@@ -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
+29 -3
View File
@@ -135,19 +135,38 @@ pub enum Commands {
}
/// Contact subcommands (`nuncio contact <verb>`).
///
/// Every verb is scoped to an `--account`, mirroring `CalSubcommand`: the
/// daemon's real contact store (`DatabaseEngine::list_contacts`/
/// `get_contact`/`save_contact`) is itself account-scoped, so there is no
/// honest way to list/search/add a contact without naming which account's
/// address book it belongs to.
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ContactSubcommand {
/// List contacts from local address book.
List,
/// List contacts in an account's address book.
List {
/// Account identifier owning the address book.
#[arg(short, long, help = "Account identifier")]
account: String,
},
/// Search contacts by name, email, or organization.
///
/// There is no server-side search RPC in the `Contacts` gRPC surface;
/// this filters the account's full `ListContacts` result client-side.
Search {
/// Account identifier owning the address book.
#[arg(short, long, help = "Account identifier")]
account: String,
/// Search query string.
#[arg(short, long, help = "Search query string")]
query: String,
},
/// Add a new contact entry to address book.
/// Add a new contact entry to an account's address book.
Add {
/// Account identifier owning the address book.
#[arg(short, long, help = "Account identifier")]
account: String,
/// Contact display name.
#[arg(short, long, help = "Display name")]
name: String,
@@ -158,6 +177,13 @@ pub enum ContactSubcommand {
#[arg(short, long, help = "Organization")]
org: Option<String>,
},
/// Synchronize an account's local contact cache with its remote CardDAV
/// server.
Sync {
/// Account identifier owning the address book.
#[arg(short, long, help = "Account identifier")]
account: String,
},
}
/// Software update subcommands (`nuncio update <verb>`).
+480 -59
View File
@@ -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::<Vec<_>>(),
"phones": contact.phones.iter().map(|p| json!({
"phone": p.phone,
"label": p.label,
"is_primary": p.is_primary,
})).collect::<Vec<_>>(),
"is_favorite": contact.is_favorite,
"interaction_count": contact.interaction_count,
})
}
/// Returns `true` if `contact`'s display name, organization, or any email
/// address contains `query` (case-insensitive). Backs `contact search`'s
/// client-side filtering over the full `ListContacts` result, since the
/// `Contacts` gRPC surface has no server-side search RPC.
fn contact_matches_query(contact: &nuncio_proto::v1::Contact, query: &str) -> bool {
let query = query.to_lowercase();
contact.display_name.to_lowercase().contains(&query)
|| contact
.organization
.as_deref()
.is_some_and(|org| org.to_lowercase().contains(&query))
|| contact
.emails
.iter()
.any(|e| e.email.to_lowercase().contains(&query))
}
/// Maps a `nuncio_core::export::ExportFormat` onto its wire-format
/// `nuncio.v1.ExportFormat` enum value, mirroring `nunciod::grpc`'s
/// server-side mapping.
@@ -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<nuncio_proto::client::AuthenticatedContactsClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
nuncio_proto::client::connect_contacts(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// `contact list`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Contacts/ListContacts` API. Returns only
/// genuinely persisted contacts from the daemon's real store -- never a
/// fabricated "0 contacts found".
async fn handle_contact_list(&self, account: &str, json_mode: bool) -> String {
let mut client = match self.connect_contacts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.list_contacts(nuncio_proto::v1::ListContactsRequest {
account_id: account.to_string(),
})
.await
{
Ok(response) => {
let contacts = response.into_inner().contacts;
if json_mode {
let contacts_json: Vec<serde_json::Value> =
contacts.iter().map(contact_proto_to_json).collect();
format_json(&json!({ "account": account, "contacts": contacts_json }))
} else {
format!(
"Contacts: {} contact(s) found in address book.",
contacts.len()
)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected list_contacts: {status}"),
json_mode,
),
}
}
/// `contact search`: fetches `account`'s full `ListContacts` result over
/// the real gRPC API, then filters it client-side by `query` against
/// display name, organization, and email address (case-insensitive) --
/// there is no server-side search RPC on the `Contacts` surface.
async fn handle_contact_search(&self, account: &str, query: &str, json_mode: bool) -> String {
let mut client = match self.connect_contacts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.list_contacts(nuncio_proto::v1::ListContactsRequest {
account_id: account.to_string(),
})
.await
{
Ok(response) => {
let matches: Vec<_> = response
.into_inner()
.contacts
.into_iter()
.filter(|c| contact_matches_query(c, query))
.collect();
if json_mode {
let contacts_json: Vec<serde_json::Value> =
matches.iter().map(contact_proto_to_json).collect();
format_json(&json!({
"account": account,
"query": query,
"contacts": contacts_json
}))
} else {
format!(
"Search ('{}'): {} contact(s) matched.",
query,
matches.len()
)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected list_contacts: {status}"),
json_mode,
),
}
}
/// `contact add`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Contacts/CreateContact` API. Persists directly to
/// the daemon's own store (never a CardDAV write-back), so the created
/// contact survives past this CLI process exiting and is visible to a
/// later, separate `contact list` invocation against the same daemon.
async fn handle_contact_add(
&self,
account: &str,
name: &str,
email: &str,
org: Option<&str>,
json_mode: bool,
) -> String {
let mut client = match self.connect_contacts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.create_contact(nuncio_proto::v1::CreateContactRequest {
account_id: account.to_string(),
display_name: name.to_string(),
organization: org.map(str::to_string),
emails: vec![nuncio_proto::v1::ContactEmail {
email: email.to_string(),
label: "work".to_string(),
is_primary: true,
}],
phones: Vec::new(),
})
.await
{
Ok(response) => match response.into_inner().contact {
Some(contact) => {
if json_mode {
format_json(&json!({
"status": "contact_created",
"contact": contact_proto_to_json(&contact),
}))
} else {
format!(
"✓ Contact '{}' saved to address book.",
contact.display_name
)
}
}
None => Self::render_error(
"nunciod daemon accepted create_contact but returned no contact",
json_mode,
),
},
Err(status) => Self::render_error(
&format!("nunciod daemon rejected create_contact: {status}"),
json_mode,
),
}
}
/// `contact sync`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Contacts/Sync` API. With no persisted per-account
/// CardDAV configuration, the daemon's production `Sync` path returns an
/// honest error -- this NEVER prints a fabricated success.
async fn handle_contact_sync(&self, account: &str, json_mode: bool) -> String {
let mut client = match self.connect_contacts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.sync(nuncio_proto::v1::ContactsSyncRequest {
account_id: account.to_string(),
})
.await
{
Ok(response) => {
let synced_count = response.into_inner().synced_count;
if json_mode {
format_json(&json!({
"status": "contacts_sync_complete",
"synced_count": synced_count,
}))
} else {
format!("Contacts synchronization complete: {synced_count} contact(s) synced")
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected contacts sync: {status}"),
json_mode,
),
}
}
/// Formats an error message consistently for both JSON and human-text
/// output modes.
fn render_error(message: &str, json_mode: bool) -> String {
@@ -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<Mutex<Option<ListContactsRequest>>>,
last_sync: Arc<Mutex<Option<ContactsSyncRequest>>>,
last_create: Arc<Mutex<Option<CreateContactRequest>>>,
}
#[tonic::async_trait]
impl ContactsService for StubContacts {
async fn sync(
&self,
request: tonic::Request<ContactsSyncRequest>,
) -> Result<tonic::Response<ContactsSyncResponse>, tonic::Status> {
let req = request.into_inner();
*self.last_sync.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(ContactsSyncResponse {
synced_count: 2,
}))
}
async fn list_contacts(
&self,
request: tonic::Request<ListContactsRequest>,
) -> Result<tonic::Response<ListContactsResponse>, tonic::Status> {
let req = request.into_inner();
*self.last_list.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(ListContactsResponse {
contacts: vec![
stub_contact("ct-stub-1", "Alice Stub", "Kof22", "alice@nuncio.mx"),
stub_contact("ct-stub-2", "Bob Other", "OtherCo", "bob@nuncio.mx"),
],
}))
}
async fn get_contact(
&self,
request: tonic::Request<GetContactRequest>,
) -> Result<tonic::Response<GetContactResponse>, tonic::Status> {
let req = request.into_inner();
if req.contact_id == "ct-stub-1" {
Ok(tonic::Response::new(GetContactResponse {
contact: Some(stub_contact(
"ct-stub-1",
"Alice Stub",
"Kof22",
"alice@nuncio.mx",
)),
}))
} else {
Err(tonic::Status::not_found(format!(
"contact '{}' not found",
req.contact_id
)))
}
}
async fn create_contact(
&self,
request: tonic::Request<CreateContactRequest>,
) -> Result<tonic::Response<CreateContactResponse>, tonic::Status> {
let req = request.into_inner();
*self.last_create.lock().unwrap_or_else(|e| e.into_inner()) = Some(req.clone());
Ok(tonic::Response::new(CreateContactResponse {
contact: Some(stub_contact(
"ct-stub-new",
&req.display_name,
req.organization.as_deref().unwrap_or(""),
req.emails.first().map(|e| e.email.as_str()).unwrap_or(""),
)),
}))
}
}
let stub = StubContacts::default();
let list_probe = stub.last_list.clone();
let sync_probe = stub.last_sync.clone();
let create_probe = stub.last_create.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(ContactsServer::new(stub))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await;
});
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("ephemeral runner initializes");
// `contact list`: proves the CLI is a real gRPC client of
// `Contacts/ListContacts` -- the exact account supplied reaches the
// daemon over the wire, and the daemon's real returned contacts
// (NOT a fabricated "0 contacts found") appear in the CLI's output.
let contact_list = runner
.execute_command(
&Commands::Contact {
action: ContactSubcommand::List {
account: "acct-stub-1".to_string(),
},
},
true,
)
.await;
assert!(contact_list.contains("ct-stub-1"));
assert!(contact_list.contains("Alice Stub"));
let recorded_list = list_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a list_contacts request");
assert_eq!(recorded_list.account_id, "acct-stub-1");
// `contact search`: proves the CLI filters the daemon's real
// `ListContacts` result client-side by query, matching only the
// contact whose organization contains "Kof22".
let contact_search = runner
.execute_command(
&Commands::Contact {
action: ContactSubcommand::Search {
account: "acct-stub-1".to_string(),
query: "kof22".to_string(),
},
},
true,
)
.await;
assert!(contact_search.contains("ct-stub-1"));
assert!(!contact_search.contains("ct-stub-2"));
// `contact add`: proves the CLI is a real gRPC client of
// `Contacts/CreateContact` and reports the daemon's real persisted
// contact, rather than fabricating a throwaway in-memory contact
// the way this command used to.
let contact_add = runner
.execute_command(
&Commands::Contact {
action: ContactSubcommand::Add {
account: "acct-stub-1".to_string(),
name: "New Person".to_string(),
email: "new.person@nuncio.mx".to_string(),
org: Some("Acme".to_string()),
},
},
true,
)
.await;
assert!(contact_add.contains(r#""status":"contact_created""#));
assert!(contact_add.contains("ct-stub-new"));
let recorded_create = create_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a create_contact request");
assert_eq!(recorded_create.account_id, "acct-stub-1");
assert_eq!(recorded_create.display_name, "New Person");
assert_eq!(recorded_create.organization.as_deref(), Some("Acme"));
// `contact sync`: proves the CLI is a real gRPC client of
// `Contacts/Sync` and reports the daemon's real `synced_count`.
let contact_sync = runner
.execute_command(
&Commands::Contact {
action: ContactSubcommand::Sync {
account: "acct-stub-1".to_string(),
},
},
true,
)
.await;
assert!(contact_sync.contains(r#""status":"contacts_sync_complete""#));
assert!(contact_sync.contains(r#""synced_count":2"#));
let recorded_sync = sync_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a sync request");
assert_eq!(recorded_sync.account_id, "acct-stub-1");
}
/// Reference-client proof: boots a stub `nuncio.v1.Filters` gRPC server
/// (mirroring the `Accounts`/`Mail` stub pattern above) and drives the
/// real `HeadlessRunner`'s
Binary file not shown.
@@ -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.
+43
View File
@@ -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<InterceptedService<Channel, BearerTokenInterceptor>>;
/// The authenticated `nuncio.v1.Contacts` client type returned by
/// [`connect_contacts`].
pub type AuthenticatedContactsClient =
ContactsClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// Dials `addr` (a `host:port` pair, e.g. `127.0.0.1:9420`) over plain HTTP
/// (the loopback gRPC transport is never TLS-wrapped; auth is via bearer
/// token instead) and returns the connected [`Channel`], shared by every
@@ -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 <token>` metadata on every
/// call.
///
/// `Contacts` is guarded by the exact same `BearerAuthInterceptor` as every
/// other service on the server side (see
/// `nunciod::grpc::serve_on_listener_with_overrides`), so this shares
/// [`BearerTokenInterceptor`] and [`dial`] with [`connect_system`] /
/// [`connect_accounts`] / [`connect_mail`] / [`connect_filters`] /
/// [`connect_export`] / [`connect_audit`] / [`connect_calendar`] rather than
/// hand-rolling yet another auth handshake.
pub async fn connect_contacts(
addr: &str,
token: &str,
) -> Result<AuthenticatedContactsClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let channel = dial(addr).await?;
Ok(ContactsClient::with_interceptor(channel, interceptor))
}
/// Opens the `nuncio.v1.System/Subscribe` server-streaming RPC on an already
/// authenticated client (as returned by [`connect_system`]), reusing the
/// bearer-token interceptor already attached to `client` rather than
@@ -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
+1
View File
@@ -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" }
+123
View File
@@ -0,0 +1,123 @@
//! Real inbound contacts synchronization routine.
//!
//! Mirrors `nunciod::calendar_sync`'s shape for the contacts engine:
//! [`sync_with_backend`] fetches contacts from a [`ContactsBackend`] for a
//! single account, then persists each contact via
//! [`DatabaseEngine::save_contact`].
//!
//! # No production entry point yet
//!
//! Unlike `sync.rs`'s `run_account_sync`/`run_all_accounts_sync`, this module
//! deliberately has NO production entry point that resolves a real account's
//! CardDAV address book collection URL and credential and builds a real
//! `CardDavClient` from them: `nuncio_core::AccountConfig` carries no CardDAV
//! fields today, so there is nothing persisted to build one from.
//! [`sync_with_backend`] is the only function here; it is exercised directly
//! in tests with `MockContactsBackend`, and it is the same seam a future
//! story's production entry point will call once per-account CardDAV
//! configuration exists.
use nuncio_contacts::ContactsBackend;
use nuncio_store::db::DatabaseError;
use thiserror::Error;
/// Errors that can occur while synchronizing a single account's contacts.
#[derive(Debug, Error)]
pub enum ContactsSyncError {
/// The contacts backend (CardDAV/mock) reported an error.
#[error("contacts backend error: {0}")]
Backend(#[from] nuncio_contacts::CardDavError),
/// A database persistence error occurred while saving synced contacts.
#[error("database error: {0}")]
Store(#[from] DatabaseError),
}
/// Fetch every contact belonging to `account_id` from `backend`, persisting
/// each via [`DatabaseEngine::save_contact`]. Returns the number of contacts
/// processed.
///
/// Pure fetch-and-persist, unit-testable directly with a
/// [`nuncio_contacts::MockContactsBackend`] -- no event-bus or credential
/// logic, mirroring `nunciod::calendar_sync::sync_with_backend`.
pub async fn sync_with_backend(
db: &nuncio_store::db::DatabaseEngine,
backend: &dyn ContactsBackend,
account_id: &str,
) -> Result<usize, ContactsSyncError> {
let contacts = backend.fetch_contacts(account_id).await?;
let mut synced = 0usize;
for contact in contacts {
db.save_contact(&contact).await?;
synced += 1;
}
Ok(synced)
}
#[cfg(test)]
mod tests {
use super::*;
use nuncio_contacts::{Contact, MockContactsBackend};
use nuncio_store::db::DatabaseEngine;
fn mock_contact(id: &str, account_id: &str, display_name: &str) -> Contact {
let mut contact = Contact::new(display_name, format!("{display_name}@nuncio.mx"));
contact.id = id.to_string();
contact.account_id = Some(account_id.to_string());
contact
}
#[tokio::test]
async fn sync_with_backend_persists_mock_contacts() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let mock = MockContactsBackend::new();
mock.add_contact(mock_contact("ct-1", "acct-contacts-1", "Alice"));
mock.add_contact(mock_contact("ct-2", "acct-contacts-1", "Bob"));
// Belongs to a different account -- must not be returned or persisted
// for `acct-contacts-1`'s sync.
mock.add_contact(mock_contact("ct-other", "acct-other", "Carol"));
let synced = sync_with_backend(&db, &mock, "acct-contacts-1")
.await
.expect("sync succeeds");
assert_eq!(synced, 2);
let persisted = db
.list_contacts("acct-contacts-1")
.await
.expect("list persisted contacts");
assert_eq!(persisted.len(), 2);
let ids: Vec<&str> = persisted.iter().map(|c| c.id.as_str()).collect();
assert!(ids.contains(&"ct-1"));
assert!(ids.contains(&"ct-2"));
}
#[tokio::test]
async fn sync_with_backend_returns_zero_for_empty_backend() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let mock = MockContactsBackend::new();
let synced = sync_with_backend(&db, &mock, "acct-empty")
.await
.expect("sync succeeds");
assert_eq!(synced, 0);
}
#[tokio::test]
async fn sync_with_backend_surfaces_backend_failure() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let mock = MockContactsBackend::new();
mock.set_should_fail(true);
let err = sync_with_backend(&db, &mock, "acct-contacts-1")
.await
.expect_err("sync fails");
assert!(matches!(err, ContactsSyncError::Backend(_)));
assert!(err.to_string().contains("contacts backend error"));
}
}
+513 -23
View File
@@ -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<Arc<dyn ContactsBackend>>,
}
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<DatabaseEngine>,
overrides: ContactsEngineOverrides,
}
#[tonic::async_trait]
impl Contacts for ContactsGrpcService {
async fn sync(
&self,
request: Request<ContactsSyncRequest>,
) -> Result<Response<ContactsSyncResponse>, 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<ListContactsRequest>,
) -> Result<Response<ListContactsResponse>, 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<GetContactRequest>,
) -> Result<Response<GetContactResponse>, 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<CreateContactRequest>,
) -> Result<Response<CreateContactResponse>, 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<Arc<str>>,
overrides: MailEngineOverrides,
calendar_overrides: CalendarEngineOverrides,
contacts_overrides: ContactsEngineOverrides,
) -> Result<(), GrpcServeError> {
let token: Arc<str> = 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<EventBus>,
db: Arc<DatabaseEngine>,
@@ -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"));
}
}
+1
View File
@@ -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;
+3 -1
View File
@@ -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;
});
+237
View File
@@ -0,0 +1,237 @@
//! Full-daemon offline end-to-end test for the contacts vertical.
//!
//! Boots a real `nunciod` daemon gRPC server
//! (`nunciod::grpc::serve_on_listener_with_overrides`) on an ephemeral
//! loopback port, backed by a real temp file-backed [`DatabaseEngine`] and a
//! [`SecretManager::mock`] vault (never the real OS keyring), with an
//! injected [`nuncio_contacts::MockContactsBackend`] standing in for real
//! network I/O (via the [`nunciod::grpc::ContactsEngineOverrides`] injection
//! seam).
//!
//! Driving ONLY the authenticated `nuncio-proto` gRPC client
//! (`connect_contacts`) with the shared bearer token -- never reaching into
//! daemon internals -- this proves:
//! a. `Contacts/Sync` fetches from the injected mock backend and persists
//! the result, awaiting full completion before returning, so the
//! synced contacts are immediately visible to `ListContacts`/
//! `GetContact`.
//! b. `Contacts/CreateContact` persists a locally-authored contact
//! directly to the daemon's real store, and it is visible via a
//! SECOND, SEPARATE client connection against the same daemon --
//! proving real cross-process-style persistence, not just
//! within-process state.
//! c. An unauthenticated call to `Contacts` is rejected with
//! `Code::Unauthenticated`, proving it is mounted behind its own
//! `BearerAuthInterceptor` exactly like every other service.
//!
//! Deterministic and fully offline: no fixed sleeps, no real network, no
//! real OS keyring.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_contacts::{Contact, MockContactsBackend};
use nuncio_core::EventBus;
use nuncio_filter::FilterEngine;
use nuncio_proto::v1::{
ContactEmail as ContactEmailProto, ContactsSyncRequest, CreateContactRequest,
GetContactRequest, ListContactsRequest,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
MailEngineOverrides,
};
use std::sync::Arc;
use tokio::net::TcpListener;
use tonic::Code;
const ACCOUNT_ID: &str = "acct-contacts-e2e-1";
fn mock_contact(id: &str, display_name: &str) -> Contact {
let mut contact = Contact::new(display_name, format!("{display_name}@nuncio.mx"));
contact.id = id.to_string();
contact.account_id = Some(ACCOUNT_ID.to_string());
contact
}
/// Boots the real daemon gRPC server, injecting `contacts_overrides`, on an
/// ephemeral loopback port backed by a real temp file-backed
/// [`DatabaseEngine`]. Returns the server address and the minted bearer
/// token; the `TempDir` guard is returned too so the caller controls its
/// lifetime for the whole test.
async fn boot_daemon(
secrets: Arc<SecretManager>,
contacts_overrides: ContactsEngineOverrides,
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
let db_path = dir.path().join("contacts_e2e.db");
let db = Arc::new(
DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("open temp file-backed database"),
);
let token_bytes = secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mint gRPC bearer token from mock vault");
let token = hex::encode(token_bytes);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let event_bus = Arc::new(EventBus::new());
let server_secrets = secrets.clone();
let server_token = token.clone();
let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener,
event_bus,
db,
server_filter_engine,
server_secrets,
server_token,
MailEngineOverrides::default(),
CalendarEngineOverrides::default(),
contacts_overrides,
)
.await;
});
(addr, token, dir)
}
/// The full offline, deterministic end-to-end proof of the contacts
/// vertical: `Sync` against an injected mock backend, then `ListContacts`/
/// `GetContact` immediately reflect the persisted result; `CreateContact`
/// persists a locally-authored contact that a SECOND, independent client
/// connection can then read back -- ALL over the authenticated
/// `nuncio.v1.Contacts` gRPC API against a live in-process daemon, with
/// protocol I/O fully mocked.
#[tokio::test]
async fn contacts_sync_create_and_list_round_trip_over_authenticated_grpc() {
let secrets = Arc::new(SecretManager::mock());
let mock_backend = MockContactsBackend::new();
mock_backend.add_contact(mock_contact("ct-e2e-1", "E2E Contact One"));
mock_backend.add_contact(mock_contact("ct-e2e-2", "E2E Contact Two"));
let contacts_overrides = ContactsEngineOverrides {
contacts_backend: Some(Arc::new(mock_backend)),
};
let (addr, token, _db_dir) = boot_daemon(secrets, contacts_overrides).await;
let addr_str = addr.to_string();
let mut contacts_client = nuncio_proto::client::connect_contacts(&addr_str, &token)
.await
.expect("contacts client connects");
// ---- (a) Sync fetches from the injected mock backend and persists ----
let sync_response = contacts_client
.sync(ContactsSyncRequest {
account_id: ACCOUNT_ID.to_string(),
})
.await
.expect("sync succeeds")
.into_inner();
assert_eq!(sync_response.synced_count, 2);
// Immediately visible, no fixed sleep: `Sync` only returns once the
// fetch-and-persist work has fully completed.
let list_response = contacts_client
.list_contacts(ListContactsRequest {
account_id: ACCOUNT_ID.to_string(),
})
.await
.expect("list_contacts succeeds")
.into_inner();
assert_eq!(list_response.contacts.len(), 2);
let synced_ids: Vec<&str> = list_response
.contacts
.iter()
.map(|c| c.id.as_str())
.collect();
assert!(synced_ids.contains(&"ct-e2e-1"));
assert!(synced_ids.contains(&"ct-e2e-2"));
let get_response = contacts_client
.get_contact(GetContactRequest {
contact_id: "ct-e2e-1".to_string(),
})
.await
.expect("get_contact succeeds")
.into_inner();
let contact = get_response.contact.expect("contact present in response");
assert_eq!(contact.display_name, "E2E Contact One");
// ---- (b) CreateContact persists real local state, visible via a
// SECOND, independent client connection ----
let create_response = contacts_client
.create_contact(CreateContactRequest {
account_id: ACCOUNT_ID.to_string(),
display_name: "CLI Added Contact".to_string(),
organization: Some("Kof22".to_string()),
emails: vec![ContactEmailProto {
email: "cli.added@kof22.com".to_string(),
label: "work".to_string(),
is_primary: true,
}],
phones: vec![],
})
.await
.expect("create_contact succeeds")
.into_inner();
let created_id = create_response
.contact
.expect("created contact present in response")
.id;
// A brand-new client connection (simulating a second, separate CLI
// invocation against the same running daemon) sees the created contact
// -- proving real persistence through the daemon's own store, not
// process-local state.
let mut second_client = nuncio_proto::client::connect_contacts(&addr_str, &token)
.await
.expect("second contacts client connects");
let second_list = second_client
.list_contacts(ListContactsRequest {
account_id: ACCOUNT_ID.to_string(),
})
.await
.expect("list_contacts succeeds on second connection")
.into_inner();
assert_eq!(second_list.contacts.len(), 3);
let second_ids: Vec<&str> = second_list.contacts.iter().map(|c| c.id.as_str()).collect();
assert!(second_ids.contains(&created_id.as_str()));
let second_get = second_client
.get_contact(GetContactRequest {
contact_id: created_id,
})
.await
.expect("get_contact succeeds on second connection")
.into_inner();
let created_contact = second_get.contact.expect("contact present in response");
assert_eq!(created_contact.display_name, "CLI Added Contact");
assert_eq!(created_contact.organization.as_deref(), Some("Kof22"));
// ---- (c) An unauthenticated call is rejected ----
// Uses the raw generated client (bypassing `connect_contacts`'s
// bearer-token interceptor) to prove `Contacts` is mounted behind its
// own `BearerAuthInterceptor`, exactly like every other service.
let mut unauthenticated_client =
nuncio_proto::v1::contacts_client::ContactsClient::connect(format!("http://{addr_str}"))
.await
.expect("client connects at the transport level without any auth");
let err = unauthenticated_client
.list_contacts(ListContactsRequest {
account_id: ACCOUNT_ID.to_string(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
+3 -1
View File
@@ -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;
});