diff --git a/src/client/permissions.rs b/src/client/permissions.rs index 6e728f9..e40f8c0 100644 --- a/src/client/permissions.rs +++ b/src/client/permissions.rs @@ -13,6 +13,16 @@ use crate::types::*; use super::Client; +/// Validates that a required string field is non-empty and non-whitespace. +fn validate_non_empty(field: &str, value: &str) -> Result<(), Error> { + if value.trim().is_empty() { + return Err(Error::InvalidArgument(format!( + "{field} must not be empty" + ))); + } + Ok(()) +} + // ── CheckPermission ────────────────────────────────────────────── /// Builder for a CheckPermission request. @@ -60,6 +70,8 @@ impl<'a> std::future::IntoFuture for CheckPermissionRequest<'a> { fn into_future(self) -> Self::IntoFuture { Box::pin(async move { + validate_non_empty("permission", &self.permission)?; + let proto_req = proto::CheckPermissionRequest { consistency: self.consistency, resource: Some(self.resource), @@ -279,6 +291,8 @@ impl<'a> LookupResourcesRequest<'a> { pub async fn send( self, ) -> Result>, Error> { + validate_non_empty("resource_type", &self.resource_type)?; + validate_non_empty("permission", &self.permission)?; let client = self.client; let (proto_req, timeout) = self.to_request_parts(); @@ -371,6 +385,8 @@ impl<'a> LookupSubjectsRequest<'a> { pub async fn send( self, ) -> Result>, Error> { + validate_non_empty("permission", &self.permission)?; + validate_non_empty("subject_type", &self.subject_type)?; let client = self.client; let (proto_req, timeout) = self.to_request_parts(); @@ -511,6 +527,8 @@ impl<'a> std::future::IntoFuture for ExpandPermissionTreeRequest<'a> { fn into_future(self) -> Self::IntoFuture { Box::pin(async move { + validate_non_empty("permission", &self.permission)?; + let proto_req = proto::ExpandPermissionTreeRequest { consistency: self.consistency, resource: Some(self.resource), @@ -682,6 +700,94 @@ mod tests { SubjectReference::new(ObjectReference::new("user", id).unwrap(), None::).unwrap() } + #[tokio::test] + async fn check_permission_empty_permission_rejected() { + let c = test_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + let subject = SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(); + let err = c.check_permission(&resource, "", &subject).await.unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[tokio::test] + async fn check_permission_whitespace_permission_rejected() { + let c = test_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + let subject = SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(); + let err = c.check_permission(&resource, " ", &subject).await.unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[tokio::test] + async fn lookup_resources_empty_permission_rejected() { + let c = test_client(); + let subject = SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(); + let result = c.lookup_resources("document", "", &subject).send().await; + assert!(matches!(result, Err(Error::InvalidArgument(_)))); + } + + #[tokio::test] + async fn lookup_resources_empty_resource_type_rejected() { + let c = test_client(); + let subject = SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(); + let result = c.lookup_resources("", "view", &subject).send().await; + assert!(matches!(result, Err(Error::InvalidArgument(_)))); + } + + #[tokio::test] + async fn lookup_subjects_empty_permission_rejected() { + let c = test_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + let result = c.lookup_subjects(&resource, "", "user").send().await; + assert!(matches!(result, Err(Error::InvalidArgument(_)))); + } + + #[tokio::test] + async fn lookup_subjects_empty_subject_type_rejected() { + let c = test_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + let result = c.lookup_subjects(&resource, "view", "").send().await; + assert!(matches!(result, Err(Error::InvalidArgument(_)))); + } + + #[tokio::test] + async fn expand_permission_tree_empty_permission_rejected() { + let c = test_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + let err = c + .expand_permission_tree(&resource, "") + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[tokio::test] + async fn expand_permission_tree_whitespace_permission_rejected() { + let c = test_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + let err = c + .expand_permission_tree(&resource, " ") + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + #[tokio::test] async fn lookup_resources_pagination_defaults() { let client = test_client(); @@ -749,7 +855,7 @@ mod tests { #[tokio::test] async fn read_relationships_pagination_defaults() { let client = test_client(); - let filter = RelationshipFilter::new("document").resource_id("rel1"); + let filter = RelationshipFilter::new("document").unwrap().resource_id("rel1"); let (proto_req, timeout) = client .read_relationships(filter) @@ -763,7 +869,7 @@ mod tests { #[tokio::test] async fn read_relationships_pagination_customized() { let client = test_client(); - let filter = RelationshipFilter::new("document").resource_id("rel2"); + let filter = RelationshipFilter::new("document").unwrap().resource_id("rel2"); let (proto_req, _) = client .read_relationships(filter) diff --git a/src/types/filter.rs b/src/types/filter.rs index 6cd4b9d..cfd6120 100644 --- a/src/types/filter.rs +++ b/src/types/filter.rs @@ -7,24 +7,32 @@ use crate::types::{Relationship, ZedToken}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RelationshipFilter { /// Resource type to filter on. - pub resource_type: String, + resource_type: String, /// Optional resource ID. - pub optional_resource_id: Option, + optional_resource_id: Option, /// Optional relation name. - pub optional_relation: Option, + optional_relation: Option, /// Optional subject filter. - pub optional_subject_filter: Option, + optional_subject_filter: Option, } impl RelationshipFilter { /// Creates a new filter for the given resource type. - pub fn new(resource_type: impl Into) -> Self { - Self { - resource_type: resource_type.into(), + /// + /// Returns `Err` if `resource_type` is empty or whitespace-only. + pub fn new(resource_type: impl Into) -> Result { + let resource_type = resource_type.into(); + if resource_type.trim().is_empty() { + return Err(Error::InvalidArgument( + "resource_type must not be empty".into(), + )); + } + Ok(Self { + resource_type, optional_resource_id: None, optional_relation: None, optional_subject_filter: None, - } + }) } /// Adds a resource ID filter. @@ -44,6 +52,26 @@ impl RelationshipFilter { self.optional_subject_filter = Some(filter); self } + + /// Returns the resource type. + pub fn resource_type(&self) -> &str { + &self.resource_type + } + + /// Returns the optional resource ID filter. + pub fn resource_id_filter(&self) -> Option<&str> { + self.optional_resource_id.as_deref() + } + + /// Returns the optional relation filter. + pub fn relation_filter(&self) -> Option<&str> { + self.optional_relation.as_deref() + } + + /// Returns the optional subject filter. + pub fn subject_filter_ref(&self) -> Option<&SubjectFilter> { + self.optional_subject_filter.as_ref() + } } impl From<&RelationshipFilter> for crate::proto::RelationshipFilter { @@ -62,21 +90,29 @@ impl From<&RelationshipFilter> for crate::proto::RelationshipFilter { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SubjectFilter { /// The subject object type. - pub subject_type: String, + subject_type: String, /// Optional subject object ID. - pub optional_subject_id: Option, + optional_subject_id: Option, /// Optional relation on the subject. - pub optional_relation: Option, + optional_relation: Option, } impl SubjectFilter { /// Creates a new subject filter for the given type. - pub fn new(subject_type: impl Into) -> Self { - Self { - subject_type: subject_type.into(), + /// + /// Returns `Err` if `subject_type` is empty or whitespace-only. + pub fn new(subject_type: impl Into) -> Result { + let subject_type = subject_type.into(); + if subject_type.trim().is_empty() { + return Err(Error::InvalidArgument( + "subject_type must not be empty".into(), + )); + } + Ok(Self { + subject_type, optional_subject_id: None, optional_relation: None, - } + }) } /// Adds a subject ID filter. @@ -90,6 +126,21 @@ impl SubjectFilter { self.optional_relation = Some(relation.into()); self } + + /// Returns the subject type. + pub fn subject_type(&self) -> &str { + &self.subject_type + } + + /// Returns the optional subject ID filter. + pub fn subject_id_filter(&self) -> Option<&str> { + self.optional_subject_id.as_deref() + } + + /// Returns the optional relation filter on the subject. + pub fn relation_filter(&self) -> Option<&str> { + self.optional_relation.as_deref() + } } impl From<&SubjectFilter> for crate::proto::SubjectFilter { @@ -133,3 +184,44 @@ impl ReadRelationshipResult { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relationship_filter_valid() { + let f = RelationshipFilter::new("document").unwrap(); + assert_eq!(f.resource_type(), "document"); + } + + #[test] + fn relationship_filter_empty_type_rejected() { + let err = RelationshipFilter::new("").unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn relationship_filter_whitespace_type_rejected() { + let err = RelationshipFilter::new(" ").unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn subject_filter_valid() { + let f = SubjectFilter::new("user").unwrap(); + assert_eq!(f.subject_type(), "user"); + } + + #[test] + fn subject_filter_empty_type_rejected() { + let err = SubjectFilter::new("").unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn subject_filter_whitespace_type_rejected() { + let err = SubjectFilter::new(" ").unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } +} diff --git a/src/types/relationship.rs b/src/types/relationship.rs index a2578d3..f360553 100644 --- a/src/types/relationship.rs +++ b/src/types/relationship.rs @@ -16,11 +16,19 @@ pub struct Caveat { impl Caveat { /// Creates a new caveat with the given name and context. - pub fn new(name: impl Into, context: HashMap) -> Self { - Self { - name: name.into(), - context, + /// + /// Returns `Err` if `name` is empty or whitespace-only. + pub fn new(name: impl Into, context: HashMap) -> Result { + let name = name.into(); + if name.trim().is_empty() { + return Err(Error::InvalidArgument( + "caveat name must not be empty".into(), + )); } + Ok(Self { + name, + context, + }) } } @@ -39,17 +47,25 @@ pub struct Relationship { impl Relationship { /// Creates a new relationship without a caveat. + /// + /// Returns `Err` if `relation` is empty or whitespace-only. pub fn new( resource: ObjectReference, relation: impl Into, subject: SubjectReference, - ) -> Self { - Self { + ) -> Result { + let relation = relation.into(); + if relation.trim().is_empty() { + return Err(Error::InvalidArgument( + "relation must not be empty".into(), + )); + } + Ok(Self { resource, - relation: relation.into(), + relation, subject, optional_caveat: None, - } + }) } /// Attaches a caveat to this relationship. @@ -71,18 +87,20 @@ impl TryFrom for Relationship { .subject .ok_or_else(|| Error::Serialization("missing subject".into()))? .try_into()?; - let optional_caveat = proto.optional_caveat.map(|c| Caveat { - name: c.caveat_name, - context: c - .context - .map(|s| s.fields.into_iter().map(|(k, v)| (k, v.into())).collect()) - .unwrap_or_default(), - }); - Ok(Relationship { - resource, - relation: proto.relation, - subject, - optional_caveat, + let optional_caveat = proto + .optional_caveat + .map(|c| { + let context = c + .context + .map(|s| s.fields.into_iter().map(|(k, v)| (k, v.into())).collect()) + .unwrap_or_default(); + Caveat::new(c.caveat_name, context) + }) + .transpose()?; + let rel = Relationship::new(resource, proto.relation, subject)?; + Ok(match optional_caveat { + Some(c) => rel.with_caveat(c), + None => rel, }) } } @@ -259,7 +277,8 @@ mod tests { None::, ) .unwrap(), - ); + ) + .unwrap(); let update = RelationshipUpdate::create(rel); assert_eq!(update.operation, Operation::Create); } @@ -275,15 +294,58 @@ mod tests { ) .unwrap(), ) - .with_caveat(Caveat::new("ip_check", HashMap::new())); + .unwrap() + .with_caveat(Caveat::new("ip_check", HashMap::new()).unwrap()); assert!(rel.optional_caveat.is_some()); assert_eq!(rel.optional_caveat.unwrap().name, "ip_check"); } + #[test] + fn relationship_empty_relation_rejected() { + let err = Relationship::new( + ObjectReference::new("doc", "1").unwrap(), + "", + SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(), + ) + .unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn relationship_whitespace_relation_rejected() { + let err = Relationship::new( + ObjectReference::new("doc", "1").unwrap(), + " ", + SubjectReference::new( + ObjectReference::new("user", "alice").unwrap(), + None::, + ) + .unwrap(), + ) + .unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn caveat_empty_name_rejected() { + let err = Caveat::new("", HashMap::new()).unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + + #[test] + fn caveat_whitespace_name_rejected() { + let err = Caveat::new(" ", HashMap::new()).unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } + #[test] fn precondition_must_exist() { use crate::types::RelationshipFilter; - let p = Precondition::must_exist(RelationshipFilter::new("document")); + let p = Precondition::must_exist(RelationshipFilter::new("document").unwrap()); assert_eq!(p.operation, PreconditionOp::MustExist); } } diff --git a/tests/integration.rs b/tests/integration.rs index 6ed7314..6a4b9c8 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -202,7 +202,7 @@ async fn write_and_check_permission() { None::, ) .unwrap(), - ))]) + ).unwrap())]) .await .expect("write_relationships failed"); @@ -252,7 +252,7 @@ async fn read_relationships() { "viewer", SubjectReference::new(ObjectReference::new("user", "bob").unwrap(), None::) .unwrap(), - )), + ).unwrap()), RelationshipUpdate::create(Relationship::new( ObjectReference::new("document", "read-1").unwrap(), "editor", @@ -261,12 +261,12 @@ async fn read_relationships() { None::, ) .unwrap(), - )), + ).unwrap()), ]) .await .unwrap(); - let filter = RelationshipFilter::new("document").resource_id("read-1"); + let filter = RelationshipFilter::new("document").unwrap().resource_id("read-1"); let mut stream = c .read_relationships(filter) .consistency(Consistency::AtLeastAsFresh(token)) @@ -298,7 +298,7 @@ async fn lookup_resources() { None::, ) .unwrap(), - )), + ).unwrap()), RelationshipUpdate::create(Relationship::new( ObjectReference::new("document", "lr-2").unwrap(), "editor", @@ -307,7 +307,7 @@ async fn lookup_resources() { None::, ) .unwrap(), - )), + ).unwrap()), ]) .await .unwrap(); @@ -346,7 +346,7 @@ async fn lookup_subjects() { "viewer", SubjectReference::new(ObjectReference::new("user", "eve").unwrap(), None::) .unwrap(), - )), + ).unwrap()), RelationshipUpdate::create(Relationship::new( ObjectReference::new("document", "ls-1").unwrap(), "viewer", @@ -355,7 +355,7 @@ async fn lookup_subjects() { None::, ) .unwrap(), - )), + ).unwrap()), ]) .await .unwrap(); @@ -391,7 +391,7 @@ async fn delete_relationships() { None::, ) .unwrap(), - ))]) + ).unwrap())]) .await .unwrap(); @@ -413,6 +413,7 @@ async fn delete_relationships() { let del_token = c .delete_relationships( RelationshipFilter::new("document") + .unwrap() .resource_id("del-1") .relation("viewer"), ) @@ -456,7 +457,7 @@ async fn watch_receives_updates() { "viewer", SubjectReference::new(ObjectReference::new("user", "hal").unwrap(), None::) .unwrap(), - ))]) + ).unwrap())]) .await .unwrap(); }); @@ -489,7 +490,7 @@ async fn bulk_check_permissions() { None::, ) .unwrap(), - ))]) + ).unwrap())]) .await .unwrap(); @@ -880,7 +881,8 @@ async fn failed_precondition_error_mapping() { None::, ) .unwrap(), - ); + ) + .unwrap(); let token = c .write_relationships(vec![RelationshipUpdate::create(rel.clone())]) @@ -889,6 +891,7 @@ async fn failed_precondition_error_mapping() { // Try to create with precondition that it must NOT exist (should fail) let filter = RelationshipFilter::new("document") + .unwrap() .resource_id("precond-1") .relation("viewer"); let result = c