use tonic::metadata::MetadataValue;
use tonic::{Request, Status};
use uuid::Uuid;
use crate::proto::udb::core::notification::entity::v1 as notif_entity_pb;
use crate::proto::udb::core::notification::services::v1 as notif_pb;
use crate::proto::udb::core::notification::services::v1::notification_service_server::NotificationService;
use crate::proto::{ErrorDetail, ErrorKind};
use crate::runtime::executor_utils::ERROR_DETAIL_METADATA_KEY;
use super::NotificationServiceImpl;
use super::config::{
NOT_RETRYABLE_STATE, TEMPLATE_NOT_FOUND, VARIABLE_MISSING, delivery_event_topic,
};
#[cfg(feature = "http-client")]
use super::delivery::parse_notification_delivery_providers_json;
use super::delivery::{NotificationDeliveryProvider, ProviderCredential};
use super::errors::{
notification_capability_status, notification_internal_status,
notification_not_retryable_status, notification_required_field,
notification_schema_not_found_status, notification_template_not_found_status,
notification_tenant_metadata_required_status, status_with_reason,
};
use super::model::{
channel_send_decision, deliverable_channels, notification_delivery_payload, status_to_db,
template_locale_or_default, template_model, template_selection_sql,
};
fn decode_detail(status: &Status) -> ErrorDetail {
let raw = status
.metadata()
.get_bin(ERROR_DETAIL_METADATA_KEY)
.expect("typed detail trailer is present");
crate::runtime::executor_utils::decode_error_detail_from_raw(&raw)
}
fn assert_schema_not_found_detail(
status: &Status,
operation: &str,
schema_code: &str,
message: &str,
) {
assert_eq!(status.code(), tonic::Code::NotFound);
assert_eq!(status.message(), message);
let detail = decode_detail(status);
assert_eq!(detail.kind, ErrorKind::Schema as i32);
assert_eq!(detail.backend, "notification");
assert_eq!(detail.operation, operation);
assert_eq!(detail.capability_required, schema_code);
assert!(!detail.retryable);
assert_eq!(detail.retry_after_ms, 0);
}
fn assert_internal_detail(status: &Status, operation: &str, message: &str) {
assert_eq!(status.code(), tonic::Code::Internal);
assert_eq!(status.message(), message);
let detail = decode_detail(status);
assert_eq!(detail.kind, ErrorKind::Internal as i32);
assert_eq!(detail.backend, "notification");
assert_eq!(detail.operation, operation);
assert!(!detail.retryable);
assert_eq!(detail.retry_after_ms, 0);
}
#[test]
fn notification_internal_status_carries_typed_detail() {
assert_internal_detail(
¬ification_internal_status(
"decode_notification_log",
"decode notification log failed: missing column",
),
"decode_notification_log",
"decode notification log failed: missing column",
);
}
#[tokio::test]
async fn list_notifications_rejects_cross_tenant_body() {
let svc = NotificationServiceImpl::new(); let mut request = Request::new(notif_pb::ListNotificationsRequest {
tenant_id: "tenant-b".to_string(),
..Default::default()
});
request
.metadata_mut()
.insert("x-tenant-id", MetadataValue::from_static("tenant-a"));
let err = svc
.list_notifications(request)
.await
.expect_err("cross-tenant body must be rejected");
assert_eq!(err.code(), tonic::Code::PermissionDenied);
}
#[tokio::test]
async fn send_notification_missing_event_type_carries_field_violation() {
let svc = NotificationServiceImpl::new(); let mut request = Request::new(notif_pb::SendNotificationRequest {
tenant_id: "tenant-a".to_string(),
event_type: " ".to_string(),
..Default::default()
});
request
.metadata_mut()
.insert("x-tenant-id", MetadataValue::from_static("tenant-a"));
let err = svc
.send_notification(request)
.await
.expect_err("missing event_type must fail");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(err.message(), "event_type is required");
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "event_type");
assert_eq!(
detail.field_violations[0].description,
"must be a non-empty notification event type"
);
}
#[tokio::test]
async fn report_delivery_missing_log_id_carries_field_violation() {
let svc = NotificationServiceImpl::new(); let mut request = Request::new(notif_pb::ReportDeliveryRequest {
tenant_id: "tenant-a".to_string(),
log_id: " ".to_string(),
channel: notif_entity_pb::NotificationChannel::Email as i32,
status: notif_entity_pb::NotificationStatus::Delivered as i32,
..Default::default()
});
request
.metadata_mut()
.insert("x-tenant-id", MetadataValue::from_static("tenant-a"));
let err = svc
.report_delivery(request)
.await
.expect_err("missing log_id must fail");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(err.message(), "log_id is required");
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "log_id");
assert_eq!(
detail.field_violations[0].description,
"must be a non-empty notification log id"
);
}
#[tokio::test]
async fn report_delivery_unspecified_status_carries_field_violation() {
let svc = NotificationServiceImpl::new(); let mut request = Request::new(notif_pb::ReportDeliveryRequest {
tenant_id: "tenant-a".to_string(),
log_id: Uuid::new_v4().to_string(),
channel: notif_entity_pb::NotificationChannel::Email as i32,
status: notif_entity_pb::NotificationStatus::Unspecified as i32,
..Default::default()
});
request
.metadata_mut()
.insert("x-tenant-id", MetadataValue::from_static("tenant-a"));
let err = svc
.report_delivery(request)
.await
.expect_err("unspecified delivery status must fail");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(
err.message(),
"a terminal delivery status (SENT|DELIVERED|FAILED|PENDING) is required"
);
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "status");
assert_eq!(
detail.field_violations[0].description,
"must be one of SENT, DELIVERED, FAILED, or PENDING"
);
}
#[tokio::test]
async fn upsert_template_missing_event_type_carries_field_violation() {
let svc = NotificationServiceImpl::new(); let request = Request::new(notif_pb::UpsertTemplateRequest {
event_type: " ".to_string(),
..Default::default()
});
let err = svc
.upsert_template(request)
.await
.expect_err("missing event_type must fail");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(err.message(), "event_type is required");
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "event_type");
}
#[test]
fn template_locale_too_long_carries_field_violation() {
let err = template_locale_or_default("en-US-extra")
.expect_err("oversized locale must fail before template lookup");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(err.message(), "locale must be 10 characters or fewer");
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "locale");
assert_eq!(
detail.field_violations[0].description,
"must be 10 characters or fewer"
);
}
#[test]
fn set_preference_missing_tenant_status_carries_field_violation() {
let err = notification_required_field(
"tenant_id",
"must be a non-empty tenant id",
"tenant_id is required",
);
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert_eq!(err.message(), "tenant_id is required");
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "tenant_id");
assert_eq!(
detail.field_violations[0].description,
"must be a non-empty tenant id"
);
}
#[test]
fn delivery_channels_exclude_suppressed_logs() {
let logs = vec![
notif_entity_pb::NotificationLog {
channel: notif_entity_pb::NotificationChannel::Email as i32,
status: notif_entity_pb::NotificationStatus::Suppressed as i32,
..Default::default()
},
notif_entity_pb::NotificationLog {
channel: notif_entity_pb::NotificationChannel::Sms as i32,
status: notif_entity_pb::NotificationStatus::Pending as i32,
..Default::default()
},
];
assert_eq!(
deliverable_channels(&logs),
vec![notif_entity_pb::NotificationChannel::Sms as i32]
);
}
#[test]
fn delivery_payload_marks_retry_events() {
let payload = notification_delivery_payload(
"log-1",
"REVIEW_ASSIGNED",
"user-1",
"tenant-a",
"project-a",
&[notif_entity_pb::NotificationChannel::Email as i32],
true,
);
assert_eq!(payload["retry"], true);
assert_eq!(payload["channels"][0], "EMAIL");
assert_eq!(payload["log_id"], "log-1");
assert_eq!(payload["channels"].as_array().map(Vec::len), Some(1));
}
#[test]
fn variable_missing_status_carries_reason_and_field_violation() {
let status = status_with_reason(
crate::runtime::executor_utils::invalid_argument_fields(
"template variable 'ResourceName' is required but was not provided",
[(
"variables.ResourceName",
"template variable is required but was not provided",
)],
),
VARIABLE_MISSING,
&[("error-variable", "ResourceName")],
);
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert_eq!(
status
.metadata()
.get("error-reason")
.and_then(|v| v.to_str().ok()),
Some(VARIABLE_MISSING)
);
assert_eq!(
status
.metadata()
.get("error-variable")
.and_then(|v| v.to_str().ok()),
Some("ResourceName")
);
let detail = decode_detail(&status);
assert_eq!(detail.kind, ErrorKind::Validation as i32);
assert_eq!(detail.field_violations.len(), 1);
assert_eq!(detail.field_violations[0].field, "variables.ResourceName");
assert_eq!(
detail.field_violations[0].description,
"template variable is required but was not provided"
);
}
#[test]
fn notification_missing_setup_capabilities_carry_typed_detail() {
for (operation, capability, message) in [
(
"native_entity_dispatch",
"runtime_native_entity_dispatch",
"notification service requires runtime native entity dispatch",
),
(
"postgres_store",
"postgres_store",
"notification service requires a Postgres-backed store (no PG pool configured)",
),
] {
let err = notification_capability_status(operation, capability, message);
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
assert_eq!(err.message(), message);
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Capability as i32);
assert_eq!(detail.backend, "notification");
assert_eq!(detail.operation, operation);
assert_eq!(detail.capability_required, capability);
assert!(!detail.retryable);
}
}
#[test]
fn notification_not_found_statuses_carry_schema_detail() {
for (operation, schema_code, message) in [
(
"get_notification",
"notification_not_found",
"notification not found",
),
(
"get_template",
"notification_template_not_found",
"template not found",
),
(
"get_preference",
"notification_preference_not_found",
"preference not found",
),
] {
assert_schema_not_found_detail(
¬ification_schema_not_found_status(operation, schema_code, message),
operation,
schema_code,
message,
);
}
}
#[test]
fn notification_template_not_found_status_keeps_reason_and_schema_detail() {
let err = notification_template_not_found_status(
"send_notification",
"no active notification template for event 'A' channel 'EMAIL' locale 'en-US'",
);
assert_eq!(
err.metadata()
.get("error-reason")
.and_then(|value| value.to_str().ok()),
Some(TEMPLATE_NOT_FOUND)
);
assert_schema_not_found_detail(
&err,
"send_notification",
"notification_template_not_found",
"no active notification template for event 'A' channel 'EMAIL' locale 'en-US'",
);
}
#[test]
fn retry_not_retryable_state_carries_policy_detail_and_reason() {
let err = notification_not_retryable_status();
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
assert_eq!(
err.message(),
"notification not found or not in a retryable (FAILED) state"
);
assert_eq!(
err.metadata()
.get("error-reason")
.and_then(|value| value.to_str().ok()),
Some(NOT_RETRYABLE_STATE)
);
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Policy as i32);
assert_eq!(detail.operation, "retry_notification");
assert_eq!(detail.policy_decision_id, "notification_not_retryable");
assert!(!detail.retryable);
}
#[test]
fn tenant_metadata_required_status_carries_permission_denied_policy_detail() {
for operation in [
"get_notification",
"retry_notification",
"get_template",
"list_templates",
] {
let err = notification_tenant_metadata_required_status(operation);
assert_eq!(err.code(), tonic::Code::PermissionDenied);
assert_eq!(err.message(), "tenant-scoped metadata is required");
let detail = decode_detail(&err);
assert_eq!(detail.kind, ErrorKind::Policy as i32);
assert_eq!(detail.operation, operation);
assert_eq!(detail.policy_decision_id, "tenant_metadata_required");
assert!(!detail.retryable);
assert_eq!(detail.retry_after_ms, 0);
}
}
#[test]
fn opted_out_channel_is_suppressed_and_excluded_from_emit_set() {
use notif_entity_pb::{NotificationChannel as C, NotificationStatus as S};
assert_eq!(
channel_send_decision(true),
("SUPPRESSED", S::Suppressed as i32)
);
assert_eq!(channel_send_decision(false), ("PENDING", S::Pending as i32));
let logs: Vec<notif_entity_pb::NotificationLog> = [(C::Email, true), (C::Sms, false)]
.into_iter()
.map(|(channel, opted_out)| notif_entity_pb::NotificationLog {
channel: channel as i32,
status: channel_send_decision(opted_out).1,
..Default::default()
})
.collect();
assert_eq!(deliverable_channels(&logs), vec![C::Sms as i32]);
}
#[test]
fn template_selection_scopes_overrides_to_the_caller_tenant() {
let m = template_model();
let sql = template_selection_sql(&m);
let tenant = m.q("tenant_id");
assert!(
sql.contains(&format!("({tenant} IS NULL OR {tenant} = $4)")),
"selection must only admit the global default or the caller's tenant: {sql}"
);
assert!(
sql.contains(&format!("ORDER BY {tenant} NULLS LAST LIMIT 1")),
"the caller's own override must outrank the global default: {sql}"
);
assert_eq!(
sql.matches("$4").count(),
1,
"the bound caller tenant must be the only tenant-shaped input: {sql}"
);
}
#[tokio::test]
async fn report_delivery_rejects_cross_tenant_body() {
let svc = NotificationServiceImpl::new(); let mut request = Request::new(notif_pb::ReportDeliveryRequest {
tenant_id: "tenant-b".to_string(),
log_id: Uuid::new_v4().to_string(),
channel: notif_entity_pb::NotificationChannel::Email as i32,
status: notif_entity_pb::NotificationStatus::Delivered as i32,
..Default::default()
});
request
.metadata_mut()
.insert("x-tenant-id", MetadataValue::from_static("tenant-a"));
let err = svc
.report_delivery(request)
.await
.expect_err("cross-tenant body must be rejected");
assert_eq!(err.code(), tonic::Code::PermissionDenied);
}
#[test]
fn delivery_event_topic_names_the_status() {
use notif_entity_pb::NotificationStatus as S;
assert_eq!(status_to_db(S::Delivered as i32), "DELIVERED");
assert_eq!(status_to_db(S::Failed as i32), "FAILED");
assert_eq!(status_to_db(S::Unspecified as i32), "UNSPECIFIED");
assert_eq!(
delivery_event_topic(status_to_db(S::Delivered as i32)),
"udb.notification.delivery.delivered.v1"
);
assert_eq!(
delivery_event_topic(status_to_db(S::Failed as i32)),
"udb.notification.delivery.failed.v1"
);
assert_eq!(
delivery_event_topic(status_to_db(S::Sent as i32)),
"udb.notification.delivery.sent.v1"
);
}
#[test]
fn provider_credentials_never_appear_in_debug() {
let canary = "udb-provider-secret-9f3a2c";
let credential = ProviderCredential(canary.to_string());
let rendered = format!("{credential:?}");
assert!(
!rendered.contains(canary),
"ProviderCredential Debug leaked the secret: {rendered}"
);
assert!(rendered.contains("[redacted]"));
let provider = NotificationDeliveryProvider {
channel: notif_entity_pb::NotificationChannel::Email as i32,
provider: "SES".to_string(),
endpoint_url: "https://email.example.com/send".to_string(),
wrapped_credential: canary.to_string(),
body_template: None,
};
let rendered = format!("{provider:?}");
assert!(
!rendered.contains(canary),
"NotificationDeliveryProvider Debug leaked the wrapped credential: {rendered}"
);
assert!(rendered.contains("[redacted]"));
}
#[test]
fn provider_url_ssrf_rejected() {
use crate::runtime::service::webhook_service::validate_webhook_target_url;
for blocked in [
"https://169.254.169.254/send", "https://127.0.0.1/send", "http://email.example.com/send", ] {
let err = validate_webhook_target_url(blocked)
.expect_err(&format!("provider URL must be SSRF-rejected: {blocked}"));
assert_eq!(err.code(), tonic::Code::InvalidArgument, "for {blocked}");
}
validate_webhook_target_url("https://email.example.com/send")
.expect("a public https provider URL should be accepted");
}
#[cfg(feature = "http-client")]
#[test]
fn delivery_provider_json_accepts_names_and_redacts_credentials() {
let providers = parse_notification_delivery_providers_json(
r#"[
{
"channel": "EMAIL",
"provider": "SES",
"endpoint_url": "https://notify.example.com/email",
"wrapped_credential": "udb-aead:v1:secret"
},
{
"channel": "sms",
"provider": "TWILIO",
"url": "https://notify.example.com/sms",
"credential": "udb-aead:v1:sms"
},
{
"channel": "bad",
"provider": "",
"endpoint_url": "",
"wrapped_credential": ""
}
]"#,
);
assert_eq!(providers.len(), 2);
assert_eq!(
providers[0].channel,
notif_entity_pb::NotificationChannel::Email as i32
);
assert_eq!(
providers[1].channel,
notif_entity_pb::NotificationChannel::Sms as i32
);
let rendered = format!("{:?}", providers[0]);
assert!(!rendered.contains("udb-aead:v1:secret"));
assert!(rendered.contains("[redacted]"));
}