feat(contacts): add Contacts gRPC vertical (proto, daemon, CLI, E2E) #218
No files matched your search
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -15,7 +15,6 @@ nuncio-mail = { path = "../nuncio-mail" }
|
||||
nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
nuncio-proto = { path = "../nuncio-proto" }
|
||||
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -135,19 +135,38 @@ pub enum Commands {
|
||||
}
|
||||
|
||||
/// Contact subcommands (`nuncio contact <verb>`).
|
||||
///
|
||||
/// Every verb is scoped to an `--account`, mirroring `CalSubcommand`: the
|
||||
/// daemon's real contact store (`DatabaseEngine::list_contacts`/
|
||||
/// `get_contact`/`save_contact`) is itself account-scoped, so there is no
|
||||
/// honest way to list/search/add a contact without naming which account's
|
||||
/// address book it belongs to.
|
||||
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ContactSubcommand {
|
||||
/// List contacts from local address book.
|
||||
List,
|
||||
/// List contacts in an account's address book.
|
||||
List {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
},
|
||||
/// Search contacts by name, email, or organization.
|
||||
///
|
||||
/// There is no server-side search RPC in the `Contacts` gRPC surface;
|
||||
/// this filters the account's full `ListContacts` result client-side.
|
||||
Search {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
/// Search query string.
|
||||
#[arg(short, long, help = "Search query string")]
|
||||
query: String,
|
||||
},
|
||||
/// Add a new contact entry to address book.
|
||||
/// Add a new contact entry to an account's address book.
|
||||
Add {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
/// Contact display name.
|
||||
#[arg(short, long, help = "Display name")]
|
||||
name: String,
|
||||
@@ -158,6 +177,13 @@ pub enum ContactSubcommand {
|
||||
#[arg(short, long, help = "Organization")]
|
||||
org: Option<String>,
|
||||
},
|
||||
/// Synchronize an account's local contact cache with its remote CardDAV
|
||||
/// server.
|
||||
Sync {
|
||||
/// Account identifier owning the address book.
|
||||
#[arg(short, long, help = "Account identifier")]
|
||||
account: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Software update subcommands (`nuncio update <verb>`).
|
||||
|
||||
+480
-59
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ workspace = true
|
||||
nuncio-core = { path = "../nuncio-core" }
|
||||
nuncio-mail = { path = "../nuncio-mail" }
|
||||
nuncio-cal = { path = "../nuncio-cal" }
|
||||
nuncio-contacts = { path = "../nuncio-contacts" }
|
||||
nuncio-store = { path = "../nuncio-store" }
|
||||
nuncio-filter = { path = "../nuncio-filter" }
|
||||
nuncio-proto = { path = "../nuncio-proto" }
|
||||
|
||||
@@ -0,0 +1,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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user