diff --git a/docs/api/subject-format.md b/docs/api/subject-format.md index 7ded98583..6d687298e 100644 --- a/docs/api/subject-format.md +++ b/docs/api/subject-format.md @@ -145,7 +145,7 @@ Array of Relation objects, each pointing to another subject. | Field | Type | Required | Description | |-------|------|-------------|-------------| | `id` | string | Yes | Unique identifier for this relation | -| `target` | string | Yes | Subject ID of the target subject, in either id form (bare means local) | +| `target` | string | Yes | Subject ID of the target subject, in either id form (bare means local). Must reference a resolvable Source: a target whose Source is not registered on this wiki is reported at write and validation time, and rejected when `$wgNeoWikiEnforceValidation` is enabled (v1 guard, [ADR 23](../adr/023-subject-sources.md)). | | `properties` | object | No | Key-value pairs of relation properties. Only included when non-empty. | Relation properties example: diff --git a/docs/api/validation-codes.md b/docs/api/validation-codes.md index 0ea9c45bc..940e139eb 100644 --- a/docs/api/validation-codes.md +++ b/docs/api/validation-codes.md @@ -198,6 +198,26 @@ Note the create dry-run endpoint (`POST /subject/validate`) instead returns `404 the update dry-run (`POST /subject/{id}/validate`) and both write endpoints surface the violation. Reconciling that asymmetry is left to the enforcement tier (ADR 21). +### `relation-target-source-unresolvable` + +Emitted by `SubjectValidator`, for every relation-typed statement in the payload — including +statements whose property is not declared on the current Schema or whose recorded type mismatches it. +`args`: `[sourceKey]` — the unresolvable Source key taken from the offending target's id. +`valuePartIndex`: never set. +`propertyName`: the property whose statement carries the offending target (which need not be declared +on the Schema). + +A Relation targets a Subject whose id names a Source that is not registered on this wiki. The v1 guard +of [ADR 23](../adr/023-subject-sources.md) ("Relations across Sources") requires a relation target to +reference a resolvable Source, so a target with an unregistered Source key is rejected at write and +validation time (blocking under `$wgNeoWikiEnforceValidation`). Schema membership says nothing about +whether a target's Source resolves, so the out-of-schema tolerance described under Known limitations +does not exempt relation targets from this check. Bare targets, and ids explicitly qualified with the +local Source (which canonicalize to bare), carry no Source key and always pass; targets whose Source +is registered pass too. This is a write/validate-time guard only: persisted Subjects are never +revalidated on read, so an already-stored foreign target is never rejected and cross-source relations +can be opened up later. + ## Known limitations (Foundation round) The PHP `SubjectValidator` performs two Subject-level checks: @@ -213,7 +233,9 @@ The PHP `SubjectValidator` performs two Subject-level checks: may have been removed from the Schema while Subjects still carry old Statements. ADR 8 says one Schema per Subject, not that every Statement on that Subject must reference an extant Property. If surfacing these as violations becomes useful (e.g. for migration UIs), it can be - added as a separate code without changing the current behavior. + added as a separate code without changing the current behavior. The one exception is + [`relation-target-source-unresolvable`](#relation-target-source-unresolvable), which fires for + relation-typed statements regardless of schema membership. ## Adding a new validation code diff --git a/extension.json b/extension.json index cbc95fdde..afc44fbf2 100644 --- a/extension.json +++ b/extension.json @@ -400,6 +400,7 @@ "neowiki-field-schema-not-found", "neowiki-field-unregistered-type", "neowiki-field-label-required", + "neowiki-field-relation-target-source-unresolvable", "neowiki-select-placeholder", "neowiki-select-no-results", "neowiki-select-unknown-option", diff --git a/i18n/en.json b/i18n/en.json index 0d3ed9f23..23c3c07a7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -86,6 +86,7 @@ "neowiki-field-schema-not-found": "The schema \"$1\" is missing. Restore it before saving.", "neowiki-field-unregistered-type": "The type \"$1\" is not available on this wiki.", "neowiki-field-label-required": "Please provide a label for the subject.", + "neowiki-field-relation-target-source-unresolvable": "The Source \"$1\" is not registered on this wiki, so it cannot be a relation target.", "neowiki-select-placeholder": "Select an option", "neowiki-select-no-results": "No matching options.", "neowiki-select-unknown-option": "(unknown option)", diff --git a/i18n/qqq.json b/i18n/qqq.json index 496c0b98b..fe491f73c 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -179,6 +179,7 @@ "neowiki-field-min-length": "Validation error shown when a text value is shorter than the property's minimum length. $1 is the minimum number of characters.", "neowiki-field-max-length": "Validation error shown when a text value is longer than the property's maximum length. $1 is the maximum number of characters.", "neowiki-field-label-required": "Form-level error shown when the backend reports the Subject is missing its required display label.", + "neowiki-field-relation-target-source-unresolvable": "Validation error shown when a relation points to a target Subject whose Source is not registered on this wiki. $1 is the unresolvable source key. This is the v1 guard of ADR 23 (Relations across Sources); bare and local-qualified targets always pass.", "neowiki-select-placeholder": "Placeholder text shown in the select dropdown before any option is chosen.", "neowiki-select-no-results": "Message shown in the multi-select dropdown when the typed text does not match any available option.", "neowiki-field-invalid-datetime": "Validation error shown when the entered date and time value cannot be parsed. DateTime values must be strict ISO 8601 / xsd:dateTime strings with an explicit timezone offset or 'Z' (for example, '2025-06-15T12:00:00Z' or '2025-06-15T12:00:00+02:00'). Partial values such as year-only or date-only are rejected, and min/max bounds are inclusive.", diff --git a/resources/ext.neowiki/tests/components/Value/RelationInput.spec.ts b/resources/ext.neowiki/tests/components/Value/RelationInput.spec.ts index de89b8dbb..421ac592f 100644 --- a/resources/ext.neowiki/tests/components/Value/RelationInput.spec.ts +++ b/resources/ext.neowiki/tests/components/Value/RelationInput.spec.ts @@ -169,6 +169,35 @@ describe( 'RelationInput', () => { expect( field.props( 'status' ) ).toBe( 'error' ); } ); + it( 'surfaces the unresolvable-source violation with the offending source key', () => { + // The server owns the Source registry, so the client never hard-rejects a foreign + // target; it only renders the backend's violation. The message must carry the source + // key ($1) so the user learns which Source is unresolvable. + setupMwMock( { + functions: [ 'message' ], + messages: { + 'neowiki-field-relation-target-source-unresolvable': + ( source: string ): string => `Unregistered source: ${ source }`, + }, + } ); + + const wrapper = newWrapper( { + property: newRelationProperty( { name: 'Owner', targetSchema: 'Company' } ), + serverViolations: [ + { + propertyName: 'Owner', + code: 'relation-target-source-unresolvable', + args: [ 'otherwiki' ], + valuePartIndex: null, + }, + ], + } ); + + const field = wrapper.findComponent( CdxField ); + expect( field.props( 'messages' ) ).toEqual( { error: 'Unregistered source: otherwiki' } ); + expect( field.props( 'status' ) ).toBe( 'error' ); + } ); + it( 'keeps a server violation suppressed while the lookup reports unmatched text', async () => { const wrapper = newWrapper( { property: newRelationProperty( { name: 'Owner', targetSchema: 'Company' } ), diff --git a/src/Application/Validation/SubjectValidator.php b/src/Application/Validation/SubjectValidator.php index d28e1e56b..23f722e42 100644 --- a/src/Application/Validation/SubjectValidator.php +++ b/src/Application/Validation/SubjectValidator.php @@ -8,15 +8,18 @@ use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyDefinition; use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName; use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; +use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry; use ProfessionalWiki\NeoWiki\Domain\Statement; use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel; use ProfessionalWiki\NeoWiki\Domain\Validation\Violation; +use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue; readonly class SubjectValidator { public function __construct( private PropertyTypeLookup $propertyTypeLookup, + private SourceRegistry $sourceRegistry, ) { } @@ -37,6 +40,8 @@ public function validate( SubjectLabel $label, StatementList $statements, Schema $this->validateStatement( $statement, $schema->getProperty( $statement->getPropertyName() ) ) ); } + + $violations = array_merge( $violations, $this->validateRelationTargetSources( $statement ) ); } return array_merge( $violations, $this->validateRequiredProperties( $statements, $schema ) ); @@ -78,6 +83,43 @@ private function validateStatement( Statement $statement, PropertyDefinition $de return $violations; } + /** + * The v1 guard of ADR 23 ("Relations across Sources"): a Relation may only target a Subject whose + * Source resolves through the registry. Runs against every statement in the payload, not only + * schema-declared ones: schema membership says nothing about whether a target's Source resolves, + * and scoping the check to the schema would let an out-of-schema or type-mismatched relation + * statement carry an unresolvable target past enforcement. Bare and local-qualified targets carry + * no source key (SubjectIdParser canonicalizes the local-qualified form to bare) and pass + * untouched. This runs only on the validate and write paths; persisted Subjects are never + * revalidated on read, so an already-stored foreign target is never rejected and cross-source + * relations can open up later. + * + * @return Violation[] + */ + private function validateRelationTargetSources( Statement $statement ): array { + $value = $statement->getValue(); + + if ( !$value instanceof RelationValue ) { + return []; + } + + $violations = []; + + foreach ( $value->relations as $relation ) { + $sourceKey = $relation->targetId->getSource(); + + if ( $sourceKey !== null && $this->sourceRegistry->getSource( $sourceKey ) === null ) { + $violations[] = new Violation( + propertyName: $statement->getPropertyName(), + code: 'relation-target-source-unresolvable', + args: [ $sourceKey ], + ); + } + } + + return $violations; + } + /** * Catch required-but-missing: the Schema declares the property as required, but no * Statement for it is present on the Subject. This also covers the "empty Value diff --git a/src/NeoWikiExtension.php b/src/NeoWikiExtension.php index 3dbb413b2..40c68d463 100644 --- a/src/NeoWikiExtension.php +++ b/src/NeoWikiExtension.php @@ -937,6 +937,7 @@ private function isValidationEnforced(): bool { public function getSubjectValidator(): SubjectValidator { return new SubjectValidator( propertyTypeLookup: $this->getPropertyTypeLookup(), + sourceRegistry: $this->getSourceRegistry(), ); } diff --git a/tests/phpunit/Application/Actions/CreateSubjectActionTest.php b/tests/phpunit/Application/Actions/CreateSubjectActionTest.php index 8a71762b5..8703b9e61 100644 --- a/tests/phpunit/Application/Actions/CreateSubjectActionTest.php +++ b/tests/phpunit/Application/Actions/CreateSubjectActionTest.php @@ -22,6 +22,7 @@ use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName; use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName; +use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry; use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue; @@ -76,7 +77,10 @@ private function newCreateSubjectAction( bool $validationEnforced = false ): Cre new SelectStatementResolver( new SelectValueResolver() ), new ProposedSubjectValidator( schemaLookup: $this->schemaLookup, - subjectValidator: new SubjectValidator( propertyTypeLookup: $registry ), + subjectValidator: new SubjectValidator( + propertyTypeLookup: $registry, + sourceRegistry: new SourceRegistry( TestData::LOCAL_SOURCE_KEY ), + ), ), $validationEnforced, ); diff --git a/tests/phpunit/Application/Actions/ReplaceSubjectActionTest.php b/tests/phpunit/Application/Actions/ReplaceSubjectActionTest.php index f197328cb..7a74db249 100644 --- a/tests/phpunit/Application/Actions/ReplaceSubjectActionTest.php +++ b/tests/phpunit/Application/Actions/ReplaceSubjectActionTest.php @@ -25,6 +25,7 @@ use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName; use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName; +use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry; use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel; @@ -76,7 +77,10 @@ private function newAction( selectStatementResolver: new SelectStatementResolver( new SelectValueResolver() ), proposedSubjectValidator: new ProposedSubjectValidator( schemaLookup: $this->schemaLookup, - subjectValidator: new SubjectValidator( propertyTypeLookup: $registry ), + subjectValidator: new SubjectValidator( + propertyTypeLookup: $registry, + sourceRegistry: new SourceRegistry( TestData::LOCAL_SOURCE_KEY ), + ), ), presenter: $this->presenterSpy, validationEnforced: $validationEnforced, diff --git a/tests/phpunit/Application/Validation/ProposedSubjectValidatorTest.php b/tests/phpunit/Application/Validation/ProposedSubjectValidatorTest.php index ed681f69e..7c0ed0e4e 100644 --- a/tests/phpunit/Application/Validation/ProposedSubjectValidatorTest.php +++ b/tests/phpunit/Application/Validation/ProposedSubjectValidatorTest.php @@ -13,7 +13,9 @@ use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyDefinitions; use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName; +use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry; use ProfessionalWiki\NeoWiki\Domain\Subject\Subject; +use ProfessionalWiki\NeoWiki\Tests\Data\TestData; use ProfessionalWiki\NeoWiki\Tests\Data\TestSubject; use ProfessionalWiki\NeoWiki\Tests\TestDoubles\InMemorySchemaLookup; @@ -33,7 +35,10 @@ protected function setUp(): void { private function newValidator(): ProposedSubjectValidator { return new ProposedSubjectValidator( schemaLookup: $this->schemaLookup, - subjectValidator: new SubjectValidator( propertyTypeLookup: PropertyTypeRegistry::withCoreTypes() ), + subjectValidator: new SubjectValidator( + propertyTypeLookup: PropertyTypeRegistry::withCoreTypes(), + sourceRegistry: new SourceRegistry( TestData::LOCAL_SOURCE_KEY ), + ), ); } diff --git a/tests/phpunit/Application/Validation/SubjectValidatorTest.php b/tests/phpunit/Application/Validation/SubjectValidatorTest.php index e553e16aa..51b9e3cc4 100644 --- a/tests/phpunit/Application/Validation/SubjectValidatorTest.php +++ b/tests/phpunit/Application/Validation/SubjectValidatorTest.php @@ -7,33 +7,52 @@ use PHPUnit\Framework\TestCase; use ProfessionalWiki\NeoWiki\Application\Validation\SubjectValidator; use ProfessionalWiki\NeoWiki\Domain\PropertyType\PropertyTypeRegistry; +use ProfessionalWiki\NeoWiki\Domain\Relation\Relation; +use ProfessionalWiki\NeoWiki\Domain\Relation\RelationId; +use ProfessionalWiki\NeoWiki\Domain\Relation\RelationProperties; use ProfessionalWiki\NeoWiki\Domain\Schema\Property\NumberProperty; +use ProfessionalWiki\NeoWiki\Domain\Schema\Property\RelationProperty; use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyCore; use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyDefinition; use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyDefinitions; use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName; use ProfessionalWiki\NeoWiki\Domain\Schema\Schema; use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName; +use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry; use ProfessionalWiki\NeoWiki\Domain\Statement; use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList; +use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel; use ProfessionalWiki\NeoWiki\Domain\Value\NumberValue; +use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue; use ProfessionalWiki\NeoWiki\Domain\Value\UnregisteredTypeValue; use ProfessionalWiki\NeoWiki\Domain\Schema\Property\UnregisteredTypeProperty; +use ProfessionalWiki\NeoWiki\Tests\TestDoubles\StubSource; /** * @covers \ProfessionalWiki\NeoWiki\Application\Validation\SubjectValidator */ class SubjectValidatorTest extends TestCase { + private const string REGISTERED_SOURCE = 'registeredwiki'; + private SubjectValidator $validator; protected function setUp(): void { $this->validator = new SubjectValidator( propertyTypeLookup: PropertyTypeRegistry::withCoreTypes(), + sourceRegistry: $this->newSourceRegistry(), ); } + private function newSourceRegistry(): SourceRegistry { + $registry = new SourceRegistry( 'localwiki' ); + $registry->register( 'decoy-before', new StubSource() ); + $registry->register( self::REGISTERED_SOURCE, new StubSource() ); + $registry->register( 'decoy-after', new StubSource() ); + return $registry; + } + public function testValidSubjectReturnsNoViolations(): void { $schema = $this->newSchema( [ 'Age' => $this->newNumberProperty() ] ); @@ -390,8 +409,114 @@ public function testRequiredPropertyOfRegisteredTypeStillReportsRequired(): void $this->assertSame( 'unregistered-type', $violations[1]->code ); } + public function testRelationTargetWithUnregisteredSourceReturnsViolation(): void { + $schema = $this->newSchema( [ 'Links' => $this->newRelationProperty() ] ); + + $violations = $this->validator->validate( + new SubjectLabel( 'X' ), + new StatementList( [ $this->newRelationStatement( 'ghostwiki:s11111111111111' ) ] ), + $schema, + ); + + $this->assertCount( 1, $violations ); + $this->assertSame( 'relation-target-source-unresolvable', $violations[0]->code ); + $this->assertEquals( new PropertyName( 'Links' ), $violations[0]->propertyName ); + $this->assertSame( [ 'ghostwiki' ], $violations[0]->args ); + } + + public function testRelationTargetWithRegisteredSourceReturnsNoViolation(): void { + $schema = $this->newSchema( [ 'Links' => $this->newRelationProperty() ] ); + + $this->assertSame( [], $this->validator->validate( + new SubjectLabel( 'X' ), + new StatementList( [ $this->newRelationStatement( self::REGISTERED_SOURCE . ':s11111111111111' ) ] ), + $schema, + ) ); + } + + public function testBareRelationTargetReturnsNoViolation(): void { + $schema = $this->newSchema( [ 'Links' => $this->newRelationProperty() ] ); + + $this->assertSame( [], $this->validator->validate( + new SubjectLabel( 'X' ), + new StatementList( [ $this->newRelationStatement( 's11111111111111' ) ] ), + $schema, + ) ); + } + + public function testOutOfSchemaRelationStatementWithUnresolvableSourceReturnsViolation(): void { + // Schema membership must not exempt a target from the guard: an out-of-schema + // relation statement would otherwise smuggle an unresolvable target past enforcement. + $schema = $this->newSchema( [ 'Known' => $this->newNumberProperty() ] ); + + $violations = $this->validator->validate( + new SubjectLabel( 'X' ), + new StatementList( [ $this->newRelationStatement( 'ghostwiki:s11111111111111' ) ] ), + $schema, + ); + + $this->assertCount( 1, $violations ); + $this->assertSame( 'relation-target-source-unresolvable', $violations[0]->code ); + $this->assertEquals( new PropertyName( 'Links' ), $violations[0]->propertyName ); + $this->assertSame( [ 'ghostwiki' ], $violations[0]->args ); + } + + public function testTypeMismatchedRelationStatementStillChecksTargetSources(): void { + // type-mismatch skips per-type validation, but must not skip the source guard. + $schema = $this->newSchema( [ 'Links' => $this->newNumberProperty() ] ); + + $violations = $this->validator->validate( + new SubjectLabel( 'X' ), + new StatementList( [ $this->newRelationStatement( 'ghostwiki:s11111111111111' ) ] ), + $schema, + ); + + $codes = array_map( static fn( $v ) => $v->code, $violations ); + $this->assertContains( 'type-mismatch', $codes ); + $this->assertContains( 'relation-target-source-unresolvable', $codes ); + } + + public function testOnlyTheRelationTargetWithAnUnresolvableSourceIsRejected(): void { + $schema = $this->newSchema( [ 'Links' => $this->newRelationProperty() ] ); + + $violations = $this->validator->validate( + new SubjectLabel( 'X' ), + new StatementList( [ $this->newRelationStatement( + 's11111111111111', + self::REGISTERED_SOURCE . ':s22222222222222', + 'ghostwiki:s33333333333333', + 'decoy-after:s44444444444444', + ) ] ), + $schema, + ); + + $this->assertCount( 1, $violations ); + $this->assertSame( 'relation-target-source-unresolvable', $violations[0]->code ); + $this->assertSame( [ 'ghostwiki' ], $violations[0]->args ); + } + // --- Helpers --- + private function newRelationProperty(): RelationProperty { + return RelationProperty::fromPartialJson( + new PropertyCore( description: '', required: false, default: null ), + [ 'relation' => 'has', 'targetSchema' => 'Person', 'multiple' => true ], + ); + } + + private function newRelationStatement( string ...$targets ): Statement { + $relations = array_map( + static fn ( string $target ): Relation => new Relation( + id: new RelationId( 'r1111111111111a' ), + targetId: new SubjectId( $target ), + properties: new RelationProperties( [] ), + ), + $targets + ); + + return new Statement( new PropertyName( 'Links' ), 'relation', new RelationValue( ...$relations ) ); + } + private function newSchema( array $properties ): Schema { return new Schema( name: new SchemaName( 'TestSchema' ), diff --git a/tests/phpunit/EntryPoints/REST/CreateSubjectApiTest.php b/tests/phpunit/EntryPoints/REST/CreateSubjectApiTest.php index 3857b3e4f..8243f3c64 100644 --- a/tests/phpunit/EntryPoints/REST/CreateSubjectApiTest.php +++ b/tests/phpunit/EntryPoints/REST/CreateSubjectApiTest.php @@ -279,4 +279,71 @@ public function testEnforcementBlockedReturns422(): void { $this->assertSame( 'required', $responseData['violations'][0]['code'] ); } + public function testRelationTargetWithUnresolvableSourceIsRejectedUnderEnforcement(): void { + $this->setMwGlobals( 'wgNeoWikiEnforceValidation', true ); + + $this->createSchema( + 'RelationEnforcementSchema', + '{"title":"RelationEnforcementSchema","propertyDefinitions":' . + '{"Owner":{"type":"relation","relation":"has","targetSchema":"Company","multiple":false}}}' + ); + + $body = $this->validBody(); + $body['schema'] = 'RelationEnforcementSchema'; + $body['statements'] = [ + 'Owner' => [ + 'propertyType' => 'relation', + 'value' => [ + [ 'id' => 'r1demo5rrrrrrr1', 'target' => 'ghostwiki:s1demo4sssssss1' ], + ], + ], + ]; + + $response = $this->executeHandler( + $this->newCreateSubjectApi(), + $this->createRequestData( $body ) + ); + + $responseData = json_decode( $response->getBody()->getContents(), true ); + + $this->assertSame( 422, $response->getStatusCode() ); + $this->assertSame( 'Validation failed', $responseData['message'] ); + $this->assertSame( 'relation-target-source-unresolvable', $responseData['violations'][0]['code'] ); + $this->assertSame( 'Owner', $responseData['violations'][0]['propertyName'] ); + $this->assertSame( [ 'ghostwiki' ], $responseData['violations'][0]['args'] ); + } + + public function testOutOfSchemaRelationTargetWithUnresolvableSourceIsRejectedUnderEnforcement(): void { + $this->setMwGlobals( 'wgNeoWikiEnforceValidation', true ); + + $this->createSchema( + 'NoRelationSchema', + '{"title":"NoRelationSchema","propertyDefinitions":{"Note":{"type":"text"}}}' + ); + + $body = $this->validBody(); + $body['schema'] = 'NoRelationSchema'; + $body['statements'] = [ + 'Owner' => [ + 'propertyType' => 'relation', + 'value' => [ + [ 'id' => 'r1demo5rrrrrrr1', 'target' => 'ghostwiki:s1demo4sssssss1' ], + ], + ], + ]; + + $response = $this->executeHandler( + $this->newCreateSubjectApi(), + $this->createRequestData( $body ) + ); + + $responseData = json_decode( $response->getBody()->getContents(), true ); + + $this->assertSame( 422, $response->getStatusCode() ); + $this->assertSame( 'Validation failed', $responseData['message'] ); + $this->assertSame( 'relation-target-source-unresolvable', $responseData['violations'][0]['code'] ); + $this->assertSame( 'Owner', $responseData['violations'][0]['propertyName'] ); + $this->assertSame( [ 'ghostwiki' ], $responseData['violations'][0]['args'] ); + } + } diff --git a/tests/phpunit/EntryPoints/REST/ValidateSubjectApiTest.php b/tests/phpunit/EntryPoints/REST/ValidateSubjectApiTest.php index 67e340bc0..8bcc94934 100644 --- a/tests/phpunit/EntryPoints/REST/ValidateSubjectApiTest.php +++ b/tests/phpunit/EntryPoints/REST/ValidateSubjectApiTest.php @@ -141,10 +141,79 @@ public function testValidSelectLabelIsResolvedAndValidates(): void { $this->assertSame( [], $responseBody['violations'] ); } + public function testRelationTargetWithUnresolvableSourceProducesViolation(): void { + $this->createSchemaWithRelationProperty(); + + $body = $this->validBody(); + $body['statements'] = [ + 'Owner' => [ + 'propertyType' => 'relation', + 'value' => [ + [ 'id' => 'r1demo5rrrrrrr1', 'target' => 'ghostwiki:s1demo4sssssss1' ], + ], + ], + ]; + + $response = $this->executeHandler( + $this->newValidateSubjectApi(), + $this->createRequestData( $body ) + ); + + $this->assertSame( 200, $response->getStatusCode() ); + $responseBody = json_decode( $response->getBody()->getContents(), true ); + $this->assertCount( 1, $responseBody['violations'] ); + $this->assertSame( 'relation-target-source-unresolvable', $responseBody['violations'][0]['code'] ); + $this->assertSame( 'Owner', $responseBody['violations'][0]['propertyName'] ); + $this->assertSame( [ 'ghostwiki' ], $responseBody['violations'][0]['args'] ); + } + + public function testLocalQualifiedRelationTargetCanonicalizesAndProducesNoViolation(): void { + $this->createSchemaWithRelationProperty(); + + $localKey = NeoWikiExtension::getInstance()->config->wikiId; + $body = $this->validBody(); + $body['statements'] = [ + 'Owner' => [ + 'propertyType' => 'relation', + 'value' => [ + [ 'id' => 'r1demo5rrrrrrr1', 'target' => $localKey . ':s1demo4sssssss1' ], + ], + ], + ]; + + $response = $this->executeHandler( + $this->newValidateSubjectApi(), + $this->createRequestData( $body ) + ); + + $this->assertSame( 200, $response->getStatusCode() ); + $responseBody = json_decode( $response->getBody()->getContents(), true ); + $this->assertSame( [], $responseBody['violations'] ); + } + private function createPages(): void { $this->createSchema( TestSubject::DEFAULT_SCHEMA_ID ); } + private function createSchemaWithRelationProperty(): void { + $json = json_encode( [ + 'title' => TestSubject::DEFAULT_SCHEMA_ID, + 'propertyDefinitions' => [ + 'Owner' => [ + 'type' => 'relation', + 'description' => '', + 'required' => false, + 'default' => null, + 'relation' => 'has', + 'targetSchema' => 'Company', + 'multiple' => false, + ], + ], + ] ); + + $this->createSchema( TestSubject::DEFAULT_SCHEMA_ID, $json ); + } + private function createSchemaWithSelectProperty(): void { $json = json_encode( [ 'title' => TestSubject::DEFAULT_SCHEMA_ID, diff --git a/tests/phpunit/EntryPoints/REST/ValidateSubjectUpdateApiTest.php b/tests/phpunit/EntryPoints/REST/ValidateSubjectUpdateApiTest.php index 2cd83b5a4..c85c46bdb 100644 --- a/tests/phpunit/EntryPoints/REST/ValidateSubjectUpdateApiTest.php +++ b/tests/phpunit/EntryPoints/REST/ValidateSubjectUpdateApiTest.php @@ -87,6 +87,44 @@ public function testInvalidConstraintReturnsViolation(): void { $this->assertSame( 'max-value', $responseBody['violations'][0]['code'] ); } + public function testRelationTargetWithUnresolvableSourceProducesViolation(): void { + $this->createSchema( + TestSubject::DEFAULT_SCHEMA_ID, + '{"title":"' . TestSubject::DEFAULT_SCHEMA_ID . '","propertyDefinitions":' + . '{"Owner":{"type":"relation","relation":"has","targetSchema":"Company","multiple":false}}}' + ); + $this->createPageWithSubjects( + 'ValidateSubjectUpdateApiUnresolvableSourceTest', + mainSubject: TestSubject::build( + id: 'sTestSU11111111', + label: new SubjectLabel( 'Test subject sTestSU11111111' ), + ) + ); + + $body = $this->validBody(); + $body['statements'] = [ + 'Owner' => [ + 'propertyType' => 'relation', + 'value' => [ + [ 'id' => 'r1demo5rrrrrrr1', 'target' => 'ghostwiki:s1demo4sssssss1' ], + ], + ], + ]; + + $response = $this->executeHandler( + $this->newValidateSubjectUpdateApi(), + $this->createRequestData( 'sTestSU11111111', $body ) + ); + + $responseBody = json_decode( $response->getBody()->getContents(), true ); + + $this->assertSame( 200, $response->getStatusCode() ); + $this->assertCount( 1, $responseBody['violations'] ); + $this->assertSame( 'relation-target-source-unresolvable', $responseBody['violations'][0]['code'] ); + $this->assertSame( 'Owner', $responseBody['violations'][0]['propertyName'] ); + $this->assertSame( [ 'ghostwiki' ], $responseBody['violations'][0]['args'] ); + } + public function testNonExistentSubjectReturns404(): void { $response = $this->executeHandler( $this->newValidateSubjectUpdateApi(), diff --git a/tests/phpunit/Persistence/MediaWiki/Subject/StatementDeserializerTest.php b/tests/phpunit/Persistence/MediaWiki/Subject/StatementDeserializerTest.php index 79b1a5bc9..def2191fe 100644 --- a/tests/phpunit/Persistence/MediaWiki/Subject/StatementDeserializerTest.php +++ b/tests/phpunit/Persistence/MediaWiki/Subject/StatementDeserializerTest.php @@ -106,6 +106,36 @@ public function testDeserializesRelation(): void { ); } + /** + * The relation-target Source guard (ADR 23) lives on the write/validate paths only. Persisted + * data is never revalidated on read, so a stored foreign-qualified target must deserialize and + * round-trip verbatim, whether or not its Source is currently registered. + */ + public function testDeserializesForeignSourceRelationTargetWithoutRejection(): void { + $statement = $this->newDeserializer()->deserialize( + 'MyRelation', + [ + 'type' => 'relation', + 'value' => [ + [ + 'id' => 'rTestSDT1111rr1', + 'target' => 'foreignwiki:sTestSDT1111111', + ], + ], + ] + ); + + $this->assertSame( + [ + [ + 'id' => 'rTestSDT1111rr1', + 'target' => 'foreignwiki:sTestSDT1111111', + ], + ], + $statement->getValue()->toScalars() + ); + } + public function testDeserializesUnregisteredTypeWithoutThrowing(): void { $this->assertEquals( new Statement(