From 6816f1c0bf9be4896d5f90095549dde5b3f461c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:39:19 +0000 Subject: [PATCH 1/5] Initial plan From 2d522ac5d22fb84cf3ee5eb6f56c111861789feb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:50:41 +0000 Subject: [PATCH 2/5] Harden input validation for string fields (empty/whitespace/invalid) Agent-Logs-Url: https://github.com/rawkode/prescience/sessions/6715d3fd-a403-48c1-9dd7-c555031c7e92 Co-authored-by: rawkode <145816+rawkode@users.noreply.github.com> --- src/client/permissions.rs | 136 ++++++++++++++++++++++++++++++++++++++ src/types/filter.rs | 73 +++++++++++++++++--- src/types/relationship.rs | 82 ++++++++++++++++++++--- tests/integration.rs | 23 ++++--- 4 files changed, 284 insertions(+), 30 deletions(-) diff --git a/src/client/permissions.rs b/src/client/permissions.rs index 4a5ae6e..04e6a40 100644 --- a/src/client/permissions.rs +++ b/src/client/permissions.rs @@ -60,6 +60,12 @@ impl<'a> std::future::IntoFuture for CheckPermissionRequest<'a> { fn into_future(self) -> Self::IntoFuture { Box::pin(async move { + if self.permission.trim().is_empty() { + return Err(Error::InvalidArgument( + "permission must not be empty".into(), + )); + } + let proto_req = proto::CheckPermissionRequest { consistency: self.consistency, resource: Some(self.resource), @@ -248,6 +254,17 @@ impl<'a> LookupResourcesRequest<'a> { pub async fn send( self, ) -> Result>, Error> { + if self.resource_type.trim().is_empty() { + return Err(Error::InvalidArgument( + "resource_type must not be empty".into(), + )); + } + if self.permission.trim().is_empty() { + return Err(Error::InvalidArgument( + "permission must not be empty".into(), + )); + } + let proto_req = proto::LookupResourcesRequest { consistency: self.consistency, resource_object_type: self.resource_type, @@ -315,6 +332,17 @@ impl<'a> LookupSubjectsRequest<'a> { pub async fn send( self, ) -> Result>, Error> { + if self.permission.trim().is_empty() { + return Err(Error::InvalidArgument( + "permission must not be empty".into(), + )); + } + if self.subject_type.trim().is_empty() { + return Err(Error::InvalidArgument( + "subject_type must not be empty".into(), + )); + } + let proto_req = proto::LookupSubjectsRequest { consistency: self.consistency, resource: Some(self.resource), @@ -433,6 +461,12 @@ impl<'a> std::future::IntoFuture for ExpandPermissionTreeRequest<'a> { fn into_future(self) -> Self::IntoFuture { Box::pin(async move { + if self.permission.trim().is_empty() { + return Err(Error::InvalidArgument( + "permission must not be empty".into(), + )); + } + let proto_req = proto::ExpandPermissionTreeRequest { consistency: self.consistency, resource: Some(self.resource), @@ -583,3 +617,105 @@ impl Client { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Client, ObjectReference, SubjectReference}; + + /// Creates a throwaway `Client` pointed at a dummy address using a lazy + /// channel that does not connect until the first RPC attempt. + fn dummy_client() -> Client { + let channel = tonic::transport::Endpoint::from_static("http://127.0.0.1:1") + .connect_lazy(); + Client::from_channel(channel, "test-token").expect("client construction failed") + } + + #[tokio::test] + async fn check_permission_empty_permission_rejected() { + let c = dummy_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 = dummy_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 = dummy_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 = dummy_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 = dummy_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 = dummy_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 = dummy_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 = dummy_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + let err = c + .expand_permission_tree(&resource, " ") + .await + .unwrap_err(); + assert!(matches!(err, Error::InvalidArgument(_))); + } +} diff --git a/src/types/filter.rs b/src/types/filter.rs index 6cd4b9d..fc6b143 100644 --- a/src/types/filter.rs +++ b/src/types/filter.rs @@ -18,13 +18,21 @@ pub struct RelationshipFilter { 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. @@ -71,12 +79,20 @@ pub struct SubjectFilter { 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. @@ -133,3 +149,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..901e46f 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. @@ -259,7 +275,8 @@ mod tests { None::, ) .unwrap(), - ); + ) + .unwrap(); let update = RelationshipUpdate::create(rel); assert_eq!(update.operation, Operation::Create); } @@ -275,15 +292,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 2aadc66..9ca1648 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(); From f3d05b17bf9cb6e335c680f1a35143dbb4860261 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:08:33 +0000 Subject: [PATCH 3/5] Address PR feedback: fix TryFrom bypasses, extract helper, privatize filter fields Agent-Logs-Url: https://github.com/rawkode/prescience/sessions/499f9c83-0b7e-47f9-b9f8-3be344b07102 Co-authored-by: rawkode <145816+rawkode@users.noreply.github.com> --- src/client/permissions.rs | 46 ++++++++++++--------------------- src/types/filter.rs | 53 ++++++++++++++++++++++++++++++++------- src/types/relationship.rs | 26 ++++++++++--------- 3 files changed, 74 insertions(+), 51 deletions(-) diff --git a/src/client/permissions.rs b/src/client/permissions.rs index 04e6a40..aed989b 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,11 +70,7 @@ impl<'a> std::future::IntoFuture for CheckPermissionRequest<'a> { fn into_future(self) -> Self::IntoFuture { Box::pin(async move { - if self.permission.trim().is_empty() { - return Err(Error::InvalidArgument( - "permission must not be empty".into(), - )); - } + validate_non_empty("permission", &self.permission)?; let proto_req = proto::CheckPermissionRequest { consistency: self.consistency, @@ -254,16 +260,8 @@ impl<'a> LookupResourcesRequest<'a> { pub async fn send( self, ) -> Result>, Error> { - if self.resource_type.trim().is_empty() { - return Err(Error::InvalidArgument( - "resource_type must not be empty".into(), - )); - } - if self.permission.trim().is_empty() { - return Err(Error::InvalidArgument( - "permission must not be empty".into(), - )); - } + validate_non_empty("resource_type", &self.resource_type)?; + validate_non_empty("permission", &self.permission)?; let proto_req = proto::LookupResourcesRequest { consistency: self.consistency, @@ -332,16 +330,8 @@ impl<'a> LookupSubjectsRequest<'a> { pub async fn send( self, ) -> Result>, Error> { - if self.permission.trim().is_empty() { - return Err(Error::InvalidArgument( - "permission must not be empty".into(), - )); - } - if self.subject_type.trim().is_empty() { - return Err(Error::InvalidArgument( - "subject_type must not be empty".into(), - )); - } + validate_non_empty("permission", &self.permission)?; + validate_non_empty("subject_type", &self.subject_type)?; let proto_req = proto::LookupSubjectsRequest { consistency: self.consistency, @@ -461,11 +451,7 @@ impl<'a> std::future::IntoFuture for ExpandPermissionTreeRequest<'a> { fn into_future(self) -> Self::IntoFuture { Box::pin(async move { - if self.permission.trim().is_empty() { - return Err(Error::InvalidArgument( - "permission must not be empty".into(), - )); - } + validate_non_empty("permission", &self.permission)?; let proto_req = proto::ExpandPermissionTreeRequest { consistency: self.consistency, diff --git a/src/types/filter.rs b/src/types/filter.rs index fc6b143..cfd6120 100644 --- a/src/types/filter.rs +++ b/src/types/filter.rs @@ -7,13 +7,13 @@ 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 { @@ -52,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 { @@ -70,11 +90,11 @@ 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 { @@ -106,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 { @@ -157,7 +192,7 @@ mod tests { #[test] fn relationship_filter_valid() { let f = RelationshipFilter::new("document").unwrap(); - assert_eq!(f.resource_type, "document"); + assert_eq!(f.resource_type(), "document"); } #[test] @@ -175,7 +210,7 @@ mod tests { #[test] fn subject_filter_valid() { let f = SubjectFilter::new("user").unwrap(); - assert_eq!(f.subject_type, "user"); + assert_eq!(f.subject_type(), "user"); } #[test] diff --git a/src/types/relationship.rs b/src/types/relationship.rs index 901e46f..f360553 100644 --- a/src/types/relationship.rs +++ b/src/types/relationship.rs @@ -87,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, }) } } From db3ef42ca4235a8e0145c3aa58d787f582279eb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:20:17 +0000 Subject: [PATCH 4/5] =?UTF-8?q?Merge=20origin/main=20into=20PR=20branch=20?= =?UTF-8?q?=E2=80=94=20resolve=20permissions.rs=20conflicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/rawkode/prescience/sessions/d01f8e99-a42e-4221-8a0e-a6d95be376b9 Co-authored-by: rawkode <145816+rawkode@users.noreply.github.com> --- src/client/experimental.rs | 86 +++++++++-- src/client/permissions.rs | 283 ++++++++++++++++++++++++++++++------- 2 files changed, 308 insertions(+), 61 deletions(-) diff --git a/src/client/experimental.rs b/src/client/experimental.rs index 8b9d326..e30775c 100644 --- a/src/client/experimental.rs +++ b/src/client/experimental.rs @@ -194,6 +194,8 @@ pub struct BulkExportRelationshipsRequest<'a> { client: &'a Client, filter: Option, consistency: Option, + optional_limit: Option, + optional_cursor: Option, timeout: Option, } @@ -204,28 +206,49 @@ impl<'a> BulkExportRelationshipsRequest<'a> { self } + /// Sets the maximum number of relationships to return in a single page. + pub fn limit(mut self, limit: u32) -> Self { + self.optional_limit = Some(limit); + self + } + + /// Sets the cursor after which results should resume. + pub fn cursor(mut self, cursor: impl Into) -> Self { + self.optional_cursor = Some(proto::Cursor { + token: cursor.into(), + }); + self + } + /// Sets a per-request timeout, overriding the client default for this request. pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = Some(timeout); self } + fn to_request_parts(self) -> (proto::ExportBulkRelationshipsRequest, Option) { + ( + proto::ExportBulkRelationshipsRequest { + consistency: self.consistency, + optional_limit: self.optional_limit.unwrap_or(0), + optional_cursor: self.optional_cursor, + optional_relationship_filter: self.filter, + }, + self.timeout, + ) + } + /// Sends the request and returns a stream of relationships. pub async fn send(self) -> Result>, Error> { - let proto_req = proto::ExportBulkRelationshipsRequest { - consistency: self.consistency, - optional_limit: 0, - optional_cursor: None, - optional_relationship_filter: self.filter, - }; + let client = self.client; + let (proto_req, timeout) = self.to_request_parts(); let mut req = tonic::Request::new(proto_req); - if let Some(t) = self.timeout { + if let Some(t) = timeout { req.set_timeout(t); } - let response = self - .client + let response = client .permissions .clone() .export_bulk_relationships(req) @@ -309,7 +332,52 @@ impl Client { client: self, filter: Some((&filter).into()), consistency: None, + optional_limit: None, + optional_cursor: None, timeout: None, } } } + +#[cfg(test)] +mod tests { + use super::*; + use tonic::transport::Channel; + + fn test_client() -> Client { + let channel = Channel::from_static("http://[::1]:50051").connect_lazy(); + Client::from_channel(channel, "test-token").unwrap() + } + + #[tokio::test] + async fn bulk_export_pagination_defaults() { + let client = test_client(); + let filter = RelationshipFilter::new("document"); + + let (proto_req, timeout) = client + .bulk_export_relationships(filter) + .to_request_parts(); + + assert_eq!(proto_req.optional_limit, 0); + assert!(proto_req.optional_cursor.is_none()); + assert!(timeout.is_none()); + } + + #[tokio::test] + async fn bulk_export_pagination_customized() { + let client = test_client(); + let filter = RelationshipFilter::new("document"); + + let (proto_req, _) = client + .bulk_export_relationships(filter) + .limit(500) + .cursor("bulk-cursor") + .to_request_parts(); + + assert_eq!(proto_req.optional_limit, 500); + assert_eq!( + proto_req.optional_cursor.as_ref().map(|c| c.token.as_str()), + Some("bulk-cursor") + ); + } +} diff --git a/src/client/permissions.rs b/src/client/permissions.rs index aed989b..e40f8c0 100644 --- a/src/client/permissions.rs +++ b/src/client/permissions.rs @@ -234,6 +234,8 @@ pub struct LookupResourcesRequest<'a> { subject: proto::SubjectReference, consistency: Option, context: Option, + optional_limit: Option, + optional_cursor: Option, timeout: Option, } @@ -250,36 +252,56 @@ impl<'a> LookupResourcesRequest<'a> { self } + /// Sets the maximum number of resources to return before the server closes the stream. + pub fn limit(mut self, limit: u32) -> Self { + self.optional_limit = Some(limit); + self + } + + /// Sets the cursor after which results should resume. + pub fn cursor(mut self, cursor: impl Into) -> Self { + self.optional_cursor = Some(proto::Cursor { + token: cursor.into(), + }); + self + } + /// Sets a per-request timeout, overriding the client default for this request. pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = Some(timeout); self } + fn to_request_parts(self) -> (proto::LookupResourcesRequest, Option) { + ( + proto::LookupResourcesRequest { + consistency: self.consistency, + resource_object_type: self.resource_type, + permission: self.permission, + subject: Some(self.subject), + context: self.context, + optional_limit: self.optional_limit.unwrap_or(0), + optional_cursor: self.optional_cursor, + }, + self.timeout, + ) + } + /// Sends the request and returns a stream of results. pub async fn send( self, ) -> Result>, Error> { validate_non_empty("resource_type", &self.resource_type)?; validate_non_empty("permission", &self.permission)?; - - let proto_req = proto::LookupResourcesRequest { - consistency: self.consistency, - resource_object_type: self.resource_type, - permission: self.permission, - subject: Some(self.subject), - context: self.context, - optional_limit: 0, - optional_cursor: None, - }; + let client = self.client; + let (proto_req, timeout) = self.to_request_parts(); let mut req = tonic::Request::new(proto_req); - if let Some(t) = self.timeout { + if let Some(t) = timeout { req.set_timeout(t); } - let response = self - .client + let response = client .permissions .clone() .lookup_resources(req) @@ -304,6 +326,8 @@ pub struct LookupSubjectsRequest<'a> { optional_subject_relation: String, consistency: Option, context: Option, + optional_concrete_limit: Option, + optional_cursor: Option, timeout: Option, } @@ -320,38 +344,58 @@ impl<'a> LookupSubjectsRequest<'a> { self } + /// Sets the maximum number of subjects to return before the server closes the stream. + pub fn limit(mut self, limit: u32) -> Self { + self.optional_concrete_limit = Some(limit); + self + } + + /// Sets the cursor after which results should resume. + pub fn cursor(mut self, cursor: impl Into) -> Self { + self.optional_cursor = Some(proto::Cursor { + token: cursor.into(), + }); + self + } + /// Sets a per-request timeout, overriding the client default for this request. pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = Some(timeout); self } + fn to_request_parts(self) -> (proto::LookupSubjectsRequest, Option) { + ( + proto::LookupSubjectsRequest { + consistency: self.consistency, + resource: Some(self.resource), + permission: self.permission, + subject_object_type: self.subject_type, + optional_subject_relation: self.optional_subject_relation, + context: self.context, + optional_concrete_limit: self.optional_concrete_limit.unwrap_or(0), + optional_cursor: self.optional_cursor, + wildcard_option: 0, + }, + self.timeout, + ) + } + /// Sends the request and returns a stream of results. pub async fn send( self, ) -> Result>, Error> { validate_non_empty("permission", &self.permission)?; validate_non_empty("subject_type", &self.subject_type)?; - - let proto_req = proto::LookupSubjectsRequest { - consistency: self.consistency, - resource: Some(self.resource), - permission: self.permission, - subject_object_type: self.subject_type, - optional_subject_relation: self.optional_subject_relation, - context: self.context, - optional_concrete_limit: 0, - optional_cursor: None, - wildcard_option: 0, - }; + let client = self.client; + let (proto_req, timeout) = self.to_request_parts(); let mut req = tonic::Request::new(proto_req); - if let Some(t) = self.timeout { + if let Some(t) = timeout { req.set_timeout(t); } - let response = self - .client + let response = client .permissions .clone() .lookup_subjects(req) @@ -372,6 +416,8 @@ pub struct ReadRelationshipsRequest<'a> { client: &'a Client, filter: proto::RelationshipFilter, consistency: Option, + optional_limit: Option, + optional_cursor: Option, timeout: Option, } @@ -388,24 +434,54 @@ impl<'a> ReadRelationshipsRequest<'a> { self } + /// Sets the maximum number of relationships to return before the server closes the stream. + pub fn limit(mut self, limit: u32) -> Self { + self.optional_limit = Some(limit); + self + } + + /// Sets the cursor after which results should resume. + pub fn cursor(mut self, cursor: impl Into) -> Self { + self.optional_cursor = Some(proto::Cursor { + token: cursor.into(), + }); + self + } + + fn to_request_parts(self) -> (proto::ReadRelationshipsRequest, Option) { + let Self { + client: _, + filter, + consistency, + optional_limit, + optional_cursor, + timeout, + } = self; + + ( + proto::ReadRelationshipsRequest { + consistency, + relationship_filter: Some(filter), + optional_limit: optional_limit.unwrap_or(0), + optional_cursor, + }, + timeout, + ) + } + /// Sends the request and returns a stream of results. pub async fn send( self, ) -> Result>, Error> { - let proto_req = proto::ReadRelationshipsRequest { - consistency: self.consistency, - relationship_filter: Some(self.filter), - optional_limit: 0, - optional_cursor: None, - }; + let client = self.client; + let (proto_req, timeout) = self.to_request_parts(); let mut req = tonic::Request::new(proto_req); - if let Some(t) = self.timeout { + if let Some(t) = timeout { req.set_timeout(t); } - let response = self - .client + let response = client .permissions .clone() .read_relationships(req) @@ -551,6 +627,8 @@ impl Client { subject: subject.into(), consistency: None, context: None, + optional_limit: None, + optional_cursor: None, timeout: None, } } @@ -572,6 +650,8 @@ impl Client { optional_subject_relation: String::new(), consistency: None, context: None, + optional_concrete_limit: None, + optional_cursor: None, timeout: None, } } @@ -584,6 +664,8 @@ impl Client { client: self, filter: (&filter).into(), consistency: None, + optional_limit: None, + optional_cursor: None, timeout: None, } } @@ -607,19 +689,20 @@ impl Client { #[cfg(test)] mod tests { use super::*; - use crate::{Client, ObjectReference, SubjectReference}; + use tonic::transport::Channel; + + fn test_client() -> Client { + let channel = Channel::from_static("http://[::1]:50051").connect_lazy(); + Client::from_channel(channel, "test-token").unwrap() + } - /// Creates a throwaway `Client` pointed at a dummy address using a lazy - /// channel that does not connect until the first RPC attempt. - fn dummy_client() -> Client { - let channel = tonic::transport::Endpoint::from_static("http://127.0.0.1:1") - .connect_lazy(); - Client::from_channel(channel, "test-token").expect("client construction failed") + fn test_subject(id: &str) -> SubjectReference { + SubjectReference::new(ObjectReference::new("user", id).unwrap(), None::).unwrap() } #[tokio::test] async fn check_permission_empty_permission_rejected() { - let c = dummy_client(); + let c = test_client(); let resource = ObjectReference::new("document", "doc1").unwrap(); let subject = SubjectReference::new( ObjectReference::new("user", "alice").unwrap(), @@ -632,7 +715,7 @@ mod tests { #[tokio::test] async fn check_permission_whitespace_permission_rejected() { - let c = dummy_client(); + let c = test_client(); let resource = ObjectReference::new("document", "doc1").unwrap(); let subject = SubjectReference::new( ObjectReference::new("user", "alice").unwrap(), @@ -645,7 +728,7 @@ mod tests { #[tokio::test] async fn lookup_resources_empty_permission_rejected() { - let c = dummy_client(); + let c = test_client(); let subject = SubjectReference::new( ObjectReference::new("user", "alice").unwrap(), None::, @@ -657,7 +740,7 @@ mod tests { #[tokio::test] async fn lookup_resources_empty_resource_type_rejected() { - let c = dummy_client(); + let c = test_client(); let subject = SubjectReference::new( ObjectReference::new("user", "alice").unwrap(), None::, @@ -669,7 +752,7 @@ mod tests { #[tokio::test] async fn lookup_subjects_empty_permission_rejected() { - let c = dummy_client(); + 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(_)))); @@ -677,7 +760,7 @@ mod tests { #[tokio::test] async fn lookup_subjects_empty_subject_type_rejected() { - let c = dummy_client(); + 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(_)))); @@ -685,7 +768,7 @@ mod tests { #[tokio::test] async fn expand_permission_tree_empty_permission_rejected() { - let c = dummy_client(); + let c = test_client(); let resource = ObjectReference::new("document", "doc1").unwrap(); let err = c .expand_permission_tree(&resource, "") @@ -696,7 +779,7 @@ mod tests { #[tokio::test] async fn expand_permission_tree_whitespace_permission_rejected() { - let c = dummy_client(); + let c = test_client(); let resource = ObjectReference::new("document", "doc1").unwrap(); let err = c .expand_permission_tree(&resource, " ") @@ -704,4 +787,100 @@ mod tests { .unwrap_err(); assert!(matches!(err, Error::InvalidArgument(_))); } + + #[tokio::test] + async fn lookup_resources_pagination_defaults() { + let client = test_client(); + let subject = test_subject("alice"); + + let (proto_req, timeout) = client + .lookup_resources("document", "view", &subject) + .to_request_parts(); + + assert_eq!(proto_req.optional_limit, 0); + assert!(proto_req.optional_cursor.is_none()); + assert!(timeout.is_none()); + } + + #[tokio::test] + async fn lookup_resources_pagination_customized() { + let client = test_client(); + let subject = test_subject("bob"); + + let (proto_req, _) = client + .lookup_resources("document", "edit", &subject) + .limit(50) + .cursor("resource-cursor") + .to_request_parts(); + + assert_eq!(proto_req.optional_limit, 50); + assert_eq!( + proto_req.optional_cursor.as_ref().map(|c| c.token.as_str()), + Some("resource-cursor") + ); + } + + #[tokio::test] + async fn lookup_subjects_pagination_defaults() { + let client = test_client(); + let resource = ObjectReference::new("document", "doc1").unwrap(); + + let (proto_req, timeout) = client + .lookup_subjects(&resource, "view", "user") + .to_request_parts(); + + assert_eq!(proto_req.optional_concrete_limit, 0); + assert!(proto_req.optional_cursor.is_none()); + assert!(timeout.is_none()); + } + + #[tokio::test] + async fn lookup_subjects_pagination_customized() { + let client = test_client(); + let resource = ObjectReference::new("document", "doc2").unwrap(); + + let (proto_req, _) = client + .lookup_subjects(&resource, "view", "user") + .limit(10) + .cursor("subjects-cursor") + .to_request_parts(); + + assert_eq!(proto_req.optional_concrete_limit, 10); + assert_eq!( + proto_req.optional_cursor.as_ref().map(|c| c.token.as_str()), + Some("subjects-cursor") + ); + } + + #[tokio::test] + async fn read_relationships_pagination_defaults() { + let client = test_client(); + let filter = RelationshipFilter::new("document").unwrap().resource_id("rel1"); + + let (proto_req, timeout) = client + .read_relationships(filter) + .to_request_parts(); + + assert_eq!(proto_req.optional_limit, 0); + assert!(proto_req.optional_cursor.is_none()); + assert!(timeout.is_none()); + } + + #[tokio::test] + async fn read_relationships_pagination_customized() { + let client = test_client(); + let filter = RelationshipFilter::new("document").unwrap().resource_id("rel2"); + + let (proto_req, _) = client + .read_relationships(filter) + .limit(5) + .cursor("rels-cursor") + .to_request_parts(); + + assert_eq!(proto_req.optional_limit, 5); + assert_eq!( + proto_req.optional_cursor.as_ref().map(|c| c.token.as_str()), + Some("rels-cursor") + ); + } } From 745601b3015aab42acd673937bb8f753f686372d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:32:01 +0000 Subject: [PATCH 5/5] =?UTF-8?q?Merge=20origin/main=20=E2=80=94=20resolve?= =?UTF-8?q?=20validation=20vs=20pagination=20conflicts,=20fix=20new=20test?= =?UTF-8?q?=20call=20sites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/rawkode/prescience/sessions/84848e26-5627-4241-ae87-321d08bb5c3d Co-authored-by: rawkode <145816+rawkode@users.noreply.github.com> --- tests/integration.rs | 464 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 462 insertions(+), 2 deletions(-) diff --git a/tests/integration.rs b/tests/integration.rs index 9ca1648..6a4b9c8 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -7,8 +7,8 @@ use std::borrow::Cow; use std::sync::Arc; use prescience::{ - Client, Consistency, ObjectReference, PermissionResult, Relationship, RelationshipFilter, - RelationshipUpdate, SubjectReference, + Client, Consistency, ObjectReference, PermissionResult, Precondition, Relationship, + RelationshipFilter, RelationshipUpdate, SubjectReference, }; use testcontainers::core::{IntoContainerPort, WaitFor}; use testcontainers::runners::AsyncRunner; @@ -523,3 +523,463 @@ async fn bulk_check_permissions() { assert!(results[0].as_ref().unwrap().is_allowed().unwrap()); assert!(!results[1].as_ref().unwrap().is_allowed().unwrap()); } + +// ── Transient Failure and Recovery ──────────────────────── + +#[tokio::test] +async fn error_retryability_unavailable() { + // Bind an ephemeral port then immediately close the listener. + // After the listener is dropped, any connection to that port gets + // ECONNREFUSED deterministically on all platforms. + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("failed to bind ephemeral port"); + let port = listener.local_addr().unwrap().port(); + drop(listener); // port is now unreachable + + let endpoint = format!("http://127.0.0.1:{}", port); + let result = Client::new(&endpoint, SPICEDB_TOKEN).await; + + match result { + Err(e) => { + // Connection refused is surfaced as a Transport error. + assert!( + matches!(e, prescience::Error::Transport(_)), + "Expected transport error, got: {:?}", + e + ); + } + Ok(_) => panic!("Expected connection to fail"), + } +} + +#[tokio::test] +async fn error_retryability_classification() { + use prescience::Error; + + // Test UNAVAILABLE is retryable + let unavailable = Error::Status { + code: tonic::Code::Unavailable, + message: "service unavailable".to_string(), + details: None, + }; + assert!( + unavailable.is_retryable(), + "UNAVAILABLE should be retryable" + ); + assert_eq!(unavailable.code(), Some(tonic::Code::Unavailable)); + + // Test DEADLINE_EXCEEDED is retryable + let deadline_exceeded = Error::Status { + code: tonic::Code::DeadlineExceeded, + message: "deadline exceeded".to_string(), + details: None, + }; + assert!( + deadline_exceeded.is_retryable(), + "DEADLINE_EXCEEDED should be retryable" + ); + assert_eq!( + deadline_exceeded.code(), + Some(tonic::Code::DeadlineExceeded) + ); + + // Test UNAUTHENTICATED is NOT retryable + let unauthenticated = Error::Status { + code: tonic::Code::Unauthenticated, + message: "invalid token".to_string(), + details: None, + }; + assert!( + !unauthenticated.is_retryable(), + "UNAUTHENTICATED should NOT be retryable" + ); + + // Test PERMISSION_DENIED is NOT retryable + let permission_denied = Error::Status { + code: tonic::Code::PermissionDenied, + message: "access denied".to_string(), + details: None, + }; + assert!( + !permission_denied.is_retryable(), + "PERMISSION_DENIED should NOT be retryable" + ); + + // Test NOT_FOUND is NOT retryable + let not_found = Error::Status { + code: tonic::Code::NotFound, + message: "not found".to_string(), + details: None, + }; + assert!( + !not_found.is_retryable(), + "NOT_FOUND should NOT be retryable" + ); + + // Test INVALID_ARGUMENT is NOT retryable + let invalid_arg = Error::Status { + code: tonic::Code::InvalidArgument, + message: "invalid input".to_string(), + details: None, + }; + assert!( + !invalid_arg.is_retryable(), + "INVALID_ARGUMENT should NOT be retryable" + ); + + // Test ALREADY_EXISTS is NOT retryable + let already_exists = Error::Status { + code: tonic::Code::AlreadyExists, + message: "already exists".to_string(), + details: None, + }; + assert!( + !already_exists.is_retryable(), + "ALREADY_EXISTS should NOT be retryable" + ); + + // Test FAILED_PRECONDITION is NOT retryable + let failed_precondition = Error::Status { + code: tonic::Code::FailedPrecondition, + message: "precondition failed".to_string(), + details: None, + }; + assert!( + !failed_precondition.is_retryable(), + "FAILED_PRECONDITION should NOT be retryable" + ); +} + +#[tokio::test] +async fn timeout_behavior_with_deadline() { + use std::time::Duration; + use tonic::transport::Endpoint; + + // Create a "black-hole" server: accepts TCP connections but never sends any + // HTTP/2 data, so the gRPC handshake never completes and every RPC times out. + // This guarantees a deterministic DEADLINE_EXCEEDED result regardless of + // how fast the test machine is. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind black-hole listener"); + let hung_port = listener.local_addr().unwrap().port(); + + // Accept one connection but never write any bytes — the HTTP/2 handshake stalls. + // A single connection is all the test needs (one RPC → one connection). + tokio::spawn(async move { + if let Ok((socket, _)) = listener.accept().await { + // Hold the socket open without responding so the timeout fires. + let _socket = socket; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + }); + + // Build a lazy channel so `connect()` doesn't block; the timeout covers + // the entire RPC including connection establishment. + let endpoint_str = format!("http://127.0.0.1:{}", hung_port); + let channel = Endpoint::from_shared(endpoint_str) + .expect("invalid endpoint") + .timeout(Duration::from_millis(500)) + .connect_lazy(); + + let timeout_client = + Client::from_channel(channel, SPICEDB_TOKEN).expect("failed to create client"); + + let result = timeout_client + .check_permission( + &ObjectReference::new("document", "timeout-test").unwrap(), + "view", + &SubjectReference::new( + ObjectReference::new("user", "timeout-user").unwrap(), + None::, + ) + .unwrap(), + ) + .await; + + match result { + Err(e) => { + let code = e + .code() + .expect("expected a gRPC status code for a timeout error"); + assert_eq!( + code, + tonic::Code::DeadlineExceeded, + "timeout should yield DEADLINE_EXCEEDED, got {:?}", + code + ); + assert!(e.is_retryable(), "DEADLINE_EXCEEDED should be retryable"); + } + Ok(_) => panic!("Expected timeout error but the operation succeeded"), + } +} + +#[cfg(feature = "watch")] +#[tokio::test] +async fn watch_resume_after_checkpoint() { + let c = spicedb().await; + + // Start watching + let mut stream = c + .watch(vec!["document"]) + .send() + .await + .expect("watch failed"); + + // Write a relationship and capture the checkpoint + let c2 = c.clone(); + let write_handle = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + c2.write_relationships(vec![RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "resume-1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "resume-user-1").unwrap(), + None::, + ) + .unwrap(), + ))]) + .await + .unwrap(); + }); + + // Get first event and its checkpoint + let event = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) + .await + .expect("timed out waiting for first watch event") + .expect("stream ended") + .expect("watch event error"); + + let checkpoint = event.checkpoint; + assert!( + !checkpoint.token().is_empty(), + "checkpoint should not be empty" + ); + + write_handle.await.unwrap(); + drop(stream); + + // Write another relationship after dropping the stream + c.write_relationships(vec![RelationshipUpdate::create(Relationship::new( + ObjectReference::new("document", "resume-2").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "resume-user-2").unwrap(), + None::, + ) + .unwrap(), + ))]) + .await + .expect("second write failed"); + + // Resume from checkpoint - should see the second write but not the first + let mut resume_stream = c + .watch(vec!["document"]) + .after_token(checkpoint) + .send() + .await + .expect("watch resume failed"); + + let event = tokio::time::timeout(std::time::Duration::from_secs(5), resume_stream.next()) + .await + .expect("timed out waiting for resumed watch event") + .expect("resumed stream ended") + .expect("resumed watch event error"); + + // The resumed event must include the post-checkpoint write for this test, + // and must not replay the pre-checkpoint write. + let has_resume_2 = event.updates.iter().any(|u| { + u.relationship.resource.object_id() == "resume-2" + && u.relationship.resource.object_type() == "document" + }); + let has_resume_1 = event.updates.iter().any(|u| { + u.relationship.resource.object_id() == "resume-1" + && u.relationship.resource.object_type() == "document" + }); + assert!( + has_resume_2, + "resumed watch should include the post-checkpoint update (resume-2); got: {:?}", + event.updates + ); + assert!( + !has_resume_1, + "resumed watch should not replay the pre-checkpoint update (resume-1); got: {:?}", + event.updates + ); +} + +#[tokio::test] +async fn unauthenticated_error_mapping() { + // Use invalid token to trigger authentication error + let endpoint = format!("http://localhost:{}", spicedb_port().await); + let bad_client = Client::new(&endpoint, "invalid-token-xyz") + .await + .expect("client creation should succeed"); + + let result = bad_client.read_schema().await; + + match result { + Err(e) => { + // SpiceDB may return either UNAUTHENTICATED or PERMISSION_DENIED for bad tokens + let code = e.code().expect("should have a status code"); + assert!( + code == tonic::Code::Unauthenticated || code == tonic::Code::PermissionDenied, + "Expected UNAUTHENTICATED or PERMISSION_DENIED, got {:?}", + code + ); + assert!( + !e.is_retryable(), + "Authentication errors should not be retryable" + ); + } + Ok(_) => panic!("Expected authentication to fail with invalid token"), + } +} + +#[tokio::test] +async fn invalid_argument_error_mapping() { + let c = spicedb().await; + + // Try to write an invalid schema to trigger INVALID_ARGUMENT + let result = c.write_schema("this is not valid schema syntax @#$").await; + + match result { + Err(e) => { + // Should get either local InvalidArgument validation or server INVALID_ARGUMENT + match &e { + prescience::Error::InvalidArgument(_) => { + // Local validation caught it + } + prescience::Error::Status { code, .. } => { + assert_eq!( + *code, + tonic::Code::InvalidArgument, + "Expected INVALID_ARGUMENT from server" + ); + assert!( + !e.is_retryable(), + "INVALID_ARGUMENT should not be retryable" + ); + } + _ => panic!("Unexpected error variant: {:?}", e), + } + } + Ok(_) => panic!("Expected invalid schema to be rejected"), + } +} + +#[tokio::test] +async fn failed_precondition_error_mapping() { + let c = spicedb().await; + + // Create a relationship + let rel = Relationship::new( + ObjectReference::new("document", "precond-1").unwrap(), + "viewer", + SubjectReference::new( + ObjectReference::new("user", "precond-user").unwrap(), + None::, + ) + .unwrap(), + ) + .unwrap(); + + let token = c + .write_relationships(vec![RelationshipUpdate::create(rel.clone())]) + .await + .expect("initial write failed"); + + // 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 + .write_relationships(vec![RelationshipUpdate::create(rel.clone())]) + .preconditions(vec![Precondition::must_not_exist(filter)]) + .await; + + match result { + Err(e) => { + assert_eq!( + e.code(), + Some(tonic::Code::FailedPrecondition), + "Expected FAILED_PRECONDITION when relationship exists" + ); + assert!( + !e.is_retryable(), + "FAILED_PRECONDITION should not be retryable" + ); + } + Ok(_) => { + panic!( + "Expected FAILED_PRECONDITION when relationship exists, but write succeeded" + ); + } + } + + // Verify relationship still exists + let verify = c + .check_permission( + &ObjectReference::new("document", "precond-1").unwrap(), + "view", + &SubjectReference::new( + ObjectReference::new("user", "precond-user").unwrap(), + None::, + ) + .unwrap(), + ) + .consistency(Consistency::AtLeastAsFresh(token)) + .await + .expect("verification check failed"); + + assert!(verify.is_allowed().unwrap()); +} + +#[tokio::test] +async fn not_found_error_mapping() { + let c = spicedb().await; + + // Try to check permission on non-existent relationship + let result = c + .check_permission( + &ObjectReference::new("document", "does-not-exist-12345").unwrap(), + "view", + &SubjectReference::new( + ObjectReference::new("user", "nobody").unwrap(), + None::, + ) + .unwrap(), + ) + .await; + + // Check permission doesn't return NOT_FOUND, it returns Denied + // So let's test NOT_FOUND with a different scenario + match result { + Ok(r) => { + // Should be denied since relationship doesn't exist + assert!(!r.is_allowed().unwrap()); + } + Err(e) => { + // Some error occurred; only validate NOT_FOUND behavior if the code is + // actually NOT_FOUND. + let code = e.code().expect("expected error to include a gRPC status code"); + assert_eq!(code, tonic::Code::NotFound, "expected NOT_FOUND error"); + assert!(!e.is_retryable(), "NOT_FOUND should not be retryable"); + } + } +} + +/// Returns the mapped port of the shared SpiceDB container. +/// +/// Reuses the single shared-container initialization in `spicedb()` so that +/// container startup, readiness retries, and schema installation remain defined +/// in one place. +async fn spicedb_port() -> u16 { + // Ensure the shared container is initialized (and schema written) exactly once. + let _ = spicedb().await; + SPICEDB + .get() + .expect("SPICEDB should be initialized by spicedb()") + .port +}