From 31a76fe678d40ceccb174a87db3fe6b4b035e9ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 13:27:53 +0000 Subject: [PATCH] Reject links whose left and right entity are the same --- libs/@local/graph/api/openapi/openapi.json | 18 ++++ .../@local/graph/store/rust/src/entity/mod.rs | 4 +- .../rust/src/entity/validation_report.rs | 8 ++ .../graph/validation/src/entity_type.rs | 12 ++- libs/@local/graph/validation/src/lib.rs | 88 ++++++++++++++++++- tests/graph/http/tests/friendship.http | 50 +++++++++++ 6 files changed, 173 insertions(+), 7 deletions(-) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 1bd5100688c..6215693aab5 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -6615,6 +6615,24 @@ ] } } + }, + { + "type": "object", + "required": [ + "type", + "error" + ], + "properties": { + "error": { + "$ref": "#/components/schemas/Report" + }, + "type": { + "type": "string", + "enum": [ + "selfReferential" + ] + } + } } ], "discriminator": { diff --git a/libs/@local/graph/store/rust/src/entity/mod.rs b/libs/@local/graph/store/rust/src/entity/mod.rs index a73bdd05431..cdef743e5e3 100644 --- a/libs/@local/graph/store/rust/src/entity/mod.rs +++ b/libs/@local/graph/store/rust/src/entity/mod.rs @@ -24,8 +24,8 @@ pub use self::{ EmptyEntityTypes, EntityRetrieval, EntityTypeRetrieval, EntityTypesError, EntityValidationReport, LinkDataStateError, LinkDataValidationReport, LinkError, LinkTargetError, LinkValidationReport, LinkedEntityError, MetadataValidationReport, - MissingLinkData, PropertyMetadataValidationReport, UnexpectedEntityType, - UnexpectedLinkData, + MissingLinkData, PropertyMetadataValidationReport, SelfReferentialLinkData, + UnexpectedEntityType, UnexpectedLinkData, }, }; diff --git a/libs/@local/graph/store/rust/src/entity/validation_report.rs b/libs/@local/graph/store/rust/src/entity/validation_report.rs index 23e27ed4811..28673f6eac9 100644 --- a/libs/@local/graph/store/rust/src/entity/validation_report.rs +++ b/libs/@local/graph/store/rust/src/entity/validation_report.rs @@ -44,6 +44,13 @@ pub struct MissingLinkData; #[must_use] pub struct UnexpectedLinkData; +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("The link has the same left and right entity: {entity_id}")] +#[must_use] +pub struct SelfReferentialLinkData { + pub entity_id: EntityId, +} + #[derive(Debug, serde::Serialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(tag = "type", content = "error", rename_all = "camelCase")] @@ -51,6 +58,7 @@ pub struct UnexpectedLinkData; pub enum LinkDataStateError { Missing(Report), Unexpected(Report), + SelfReferential(Report), } #[derive(Debug, serde::Serialize)] diff --git a/libs/@local/graph/validation/src/entity_type.rs b/libs/@local/graph/validation/src/entity_type.rs index 99290473077..18622a31bb9 100644 --- a/libs/@local/graph/validation/src/entity_type.rs +++ b/libs/@local/graph/validation/src/entity_type.rs @@ -9,8 +9,8 @@ use futures::{StreamExt as _, TryStreamExt as _, stream}; use hash_graph_store::entity::{ EntityRetrieval, EntityTypeRetrieval, LinkDataStateError, LinkDataValidationReport, LinkError, LinkTargetError, LinkValidationReport, LinkedEntityError, MissingLinkData, - PropertyMetadataValidationReport, UnexpectedEntityType, UnexpectedLinkData, - ValidateEntityComponents, + PropertyMetadataValidationReport, SelfReferentialLinkData, UnexpectedEntityType, + UnexpectedLinkData, ValidateEntityComponents, }; use hash_graph_types::{ knowledge::property::visitor::{ @@ -92,6 +92,14 @@ where } if components.link_validation { + if is_link && link_data.left_entity_id == link_data.right_entity_id { + validation_report.link_data = Some(LinkDataStateError::SelfReferential( + Report::new(SelfReferentialLinkData { + entity_id: link_data.left_entity_id, + }), + )); + } + validation_report.link_data_validation = link_data.validate(schema, components, context).await; } diff --git a/libs/@local/graph/validation/src/lib.rs b/libs/@local/graph/validation/src/lib.rs index b2ccc85712a..a1b2b739799 100644 --- a/libs/@local/graph/validation/src/lib.rs +++ b/libs/@local/graph/validation/src/lib.rs @@ -48,7 +48,9 @@ mod tests { use std::collections::HashMap; use error_stack::ResultExt as _; - use hash_graph_store::entity::ValidateEntityComponents; + use hash_graph_store::entity::{ + LinkDataStateError, LinkValidationReport, ValidateEntityComponents, + }; use hash_graph_temporal_versioning::{ ClosedTemporalBound, Interval, OpenTemporalBound, Timestamp, }; @@ -64,10 +66,11 @@ mod tests { use thiserror::Error; use type_system::{ knowledge::{ - entity::id::EntityUuid, + entity::{LinkData, id::EntityUuid}, property::{ Property, PropertyObject, PropertyObjectWithMetadata, PropertyValueWithMetadata, - PropertyWithMetadata, metadata::PropertyMetadata, + PropertyWithMetadata, + metadata::{PropertyMetadata, PropertyProvenance}, }, value::{ValueMetadata, metadata::ValueProvenance}, }, @@ -503,4 +506,83 @@ mod tests { .await?; Ok(PropertyValueWithMetadata { value, metadata }) } + + async fn validate_friend_of_link( + left_entity_id: EntityId, + right_entity_id: EntityId, + ) -> LinkValidationReport { + let mut ontology_type_resolver = OntologyTypeResolver::default(); + for entity_type in [ + hash_graph_test_data::entity_type::LINK_V1, + hash_graph_test_data::entity_type::link::FRIEND_OF_V1, + ] { + let entity_type = serde_json::from_str::(entity_type) + .expect("entity type should be valid JSON"); + ontology_type_resolver.add_unresolved_entity_type( + EntityTypeUuid::from_url(&entity_type.id), + Arc::new(entity_type), + ); + } + + let friend_of = serde_json::from_str::( + hash_graph_test_data::entity_type::link::FRIEND_OF_V1, + ) + .expect("entity type should be valid JSON"); + let resolved_data = ontology_type_resolver + .resolve_entity_type_metadata(EntityTypeUuid::from_url(&friend_of.id)) + .expect("entity type should be resolvable"); + let schema = ClosedMultiEntityType::from_multi_type_closed_schema(iter::once( + ClosedEntityType::from_resolve_data(friend_of, &resolved_data) + .expect("entity type should be closable"), + )) + .expect("multi entity type should be closable"); + + let link_data = LinkData { + left_entity_id, + right_entity_id, + left_entity_confidence: None, + left_entity_provenance: PropertyProvenance::default(), + right_entity_confidence: None, + right_entity_provenance: PropertyProvenance::default(), + }; + + Some(&link_data) + .validate( + &schema, + ValidateEntityComponents::full(), + &Provider::new([], [], [], []), + ) + .await + } + + fn entity_id(uuid: u128) -> EntityId { + EntityId { + web_id: WebId::new(EntityUuid::new(Uuid::from_u128(1))), + entity_uuid: EntityUuid::new(Uuid::from_u128(uuid)), + draft_id: None, + } + } + + #[tokio::test] + async fn link_with_same_left_and_right_entity_is_rejected() { + let report = validate_friend_of_link(entity_id(2), entity_id(2)).await; + + assert!( + matches!( + report.link_data, + Some(LinkDataStateError::SelfReferential(_)) + ), + "link data with the same left and right entity should be rejected, got {report:?}" + ); + } + + #[tokio::test] + async fn link_with_different_left_and_right_entity_is_not_self_referential() { + let report = validate_friend_of_link(entity_id(2), entity_id(3)).await; + + assert!( + report.link_data.is_none(), + "link data with different left and right entities should be accepted, got {report:?}" + ); + } } diff --git a/tests/graph/http/tests/friendship.http b/tests/graph/http/tests/friendship.http index 840435ed888..53147252fb3 100644 --- a/tests/graph/http/tests/friendship.http +++ b/tests/graph/http/tests/friendship.http @@ -1555,6 +1555,56 @@ X-Authenticated-User-Actor-Id: {{user_id}} }); %} +### Validate link from a person to itself +POST http://127.0.0.1:4000/entities/validate +Content-Type: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "entityTypes": ["{{friendship_link_entity_type_id}}"], + "properties": { "value": {} }, + "linkData": { + "leftEntityId": "{{person_a_entity_id}}", + "rightEntityId": "{{person_a_entity_id}}" + } +} + +> {% + client.test("status", function() { + client.assert(response.status === 200, "Response status is not 200"); + client.assert(response.body["0"].link.linkData.type === "selfReferential", "Self-referential link validation error expected"); + }); +%} + +### Insert link from a person to itself +POST http://127.0.0.1:4000/entities +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "webId": "{{user_id}}", + "properties": { "value": {} }, + "entityTypeIds": ["{{friendship_link_entity_type_id}}"], + "linkData": { + "leftEntityId": "{{person_a_entity_id}}", + "rightEntityId": "{{person_a_entity_id}}" + }, + "draft": true, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + } +} + +> {% + client.test("status", function() { + client.assert(response.status === 400, "Response status is not 400"); + }); +%} + ### Insert link between entities POST http://127.0.0.1:4000/entities Content-Type: application/json