REST: validate JSON in tests

This applies JSON Schema validation in phpunit tests where appropriate:

1) In ModuleSpecHandlerTest, the generated OpenApi specs are validated
against the OpenAPI 3 schema.

2) In RestStructureTest, module definition files are validated against
   the mwapi schema.

This patch introduces a new trait to make it easy for phpunit test cases
to perform validation.

This patch also fixes some issues with the docs/rest/mwapi-1.0.json
schema and the includes/Rest/content.v1.json module definition.

Change-Id: I966cddb337c9373ed3a369496548a8d8c538ae84
This commit is contained in:
daniel 2024-08-29 21:01:46 +02:00 committed by BPirkle
parent 58f644abc2
commit d7ed4b14bb
18 changed files with 2003 additions and 61 deletions

View file

@ -12,10 +12,13 @@
/docs/latex/
/vendor/
/tests/coverage/
# Test data
/tests/phpunit/data/registration/duplicate_keys.json
/tests/phpunit/data/resourceloader/codex/
/tests/phpunit/data/resourceloader/codex-devmode/
/tests/phpunit/unit/includes/Settings/Source/fixtures/bad.json
/tests/phpunit/**/*malformed*.json
/maintenance/benchmarks/data/
# Nested projects

View file

@ -1,5 +1,5 @@
{
"$schema": "http://json-schema.org/draft-06/schema#",
"$schema": "http://json-schema.org/draft-04/schema#",
"$id": "https://www.mediawiki.org/schema/mwapi-1.0",
"title": "MediaWiki REST API module definition",
"description": "Module definition files provide meta-data about modules and define the available routes. They are similar to OpenAPI specs.",
@ -65,12 +65,11 @@
},
"OperationImpl": {
"oneOf": [
{ "$ref": "#/definitions/Handler" },
{ "$ref": "#/definitions/Redirect" }
],
"additionalProperties": false
{ "$ref": "#/definitions/WithHandler" },
{ "$ref": "#/definitions/WithRedirect" }
]
},
"Handler": {
"WithHandler": {
"required": [ "handler" ],
"properties": {
"handler": {
@ -104,9 +103,10 @@
"item": { "type": "string" },
"description": "List of services to pass as arguments. Each name will be looked up in MediaWikiServices. If the service is unknown the parameter is set to 'null' instead of causing an error."
}
}
},
"additionalProperties": true
},
"Redirect": {
"WithRedirect": {
"required": [ "redirect" ],
"properties": {
"redirect": {
@ -115,7 +115,8 @@
"properties": {
"path": { "type": "string" },
"code": { "type": "integer" }
}
},
"additionalProperties": false
}
}
}

View file

@ -2,7 +2,9 @@
"mwapi": "1.0.0",
"moduleId": "content/v1",
"info": {
"version": "1.0"
"version": "1.0",
"title": "Page content",
"description": "Provides access to the content and meta-data of pages and revisions"
},
"paths": {
"/page": {

View file

@ -53,6 +53,7 @@ $wgAutoloadClasses += [
# tests/phpunit
'DynamicPropertyTestHelper' => "$testDir/phpunit/DynamicPropertyTestHelper.php",
'EmptyResourceLoader' => "$testDir/phpunit/ResourceLoaderTestCase.php",
'JsonSchemaAssertionTrait' => "$testDir/phpunit/JsonSchemaAssertionTrait.php",
'MediaWiki\\Tests\\ResourceLoader\\EmptyResourceLoader' => "$testDir/phpunit/ResourceLoaderTestCase.php",
'HamcrestPHPUnitIntegration' => "$testDir/phpunit/HamcrestPHPUnitIntegration.php",
'MediaWikiCoversValidator' => "$testDir/phpunit/MediaWikiCoversValidator.php",

View file

@ -0,0 +1,113 @@
<?php
/**
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
* http://www.gnu.org/copyleft/gpl.html
*/
use JsonSchema\Constraints\Constraint;
use JsonSchema\Constraints\Factory;
use JsonSchema\Validator;
/**
* Trait for validating data structures against JSON schemas.
*
* Based on the jsonrainbow/json-schema package, supports Draft-3 and Draft-4
* schemas.
*
* @stable to use in extensions
* @since 1.43
*/
trait JsonSchemaAssertionTrait {
/**
* Load data from a JSON file.
*
* @param string $jsonFile The path of the JSON file
*
* @return mixed
*/
private static function loadJsonData( string $jsonFile ) {
$json = file_get_contents( $jsonFile );
if ( $json === false ) {
throw new InvalidArgumentException( "Unable to load content of $jsonFile" );
}
return json_decode( $json, false, JSON_THROW_ON_ERROR );
}
/**
* @param string|array $schema A JSON schema as an array structure, or the
* file path of a JSON schema file.
* @param string|array|stdClass $json A JSONic data structure, or a JSON string.
* @param array<string, string> $resources Mapping of schema IDs to local files,
* to avoid loading schemas over the network during testing.
*/
private function assertMatchesJsonSchema( $schema, $json, array $resources = [] ): void {
if ( is_string( $json ) ) {
try {
$json = json_decode( $json, false, JSON_THROW_ON_ERROR );
} catch ( JsonException $ex ) {
self::fail( 'Invalid JSON: ' . $ex->getMessage() );
}
}
if ( is_string( $schema ) ) {
// Let the JsonException propagate, this indicates a bug in the test,
// not a test failure.
$schema = self::loadJsonData( $schema );
}
$factory = new Factory();
foreach ( $resources as $id => $rc ) {
$factory->getSchemaStorage()->addSchema(
rtrim( $id, '#' ),
self::loadJsonData( $rc )
);
}
$validator = new Validator( $factory );
$validator->validate(
$json, $schema,
Constraint::CHECK_MODE_TYPE_CAST
);
if ( !$validator->isValid() ) {
foreach ( $validator->getErrors() as $error ) {
$error = json_encode( $error );
self::fail( "JSON schema validation failed: $error" );
}
}
$this->addToAssertionCount( 1 );
}
/**
* Validate a JSON schema.
*
* Currently only works for schemas that match Draft-4.
*
* @param array $schema The schema to validate.
* @throws LogicException if the schema is invalid
*/
public function assertValidJsonSchema( array $schema ): void {
// Load the draft-04 schema from the local file
$metaSchema = MW_INSTALL_PATH . '/vendor/justinrainbow/json-schema/dist/' .
'schema/json-schema-draft-04.json';
self::assertMatchesJsonSchema( $metaSchema, $schema );
}
}

View file

@ -2,6 +2,7 @@
namespace MediaWiki\Tests\Rest\Handler;
use JsonSchemaAssertionTrait;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Context\RequestContext;
use MediaWiki\MainConfigNames;
@ -28,6 +29,7 @@ use Wikimedia\ParamValidator\ParamValidator;
*/
class ModuleSpecHandlerTest extends MediaWikiIntegrationTestCase {
use HandlerTestTrait;
use JsonSchemaAssertionTrait;
private function createRouter(
RequestInterface $request,
@ -81,31 +83,11 @@ class ModuleSpecHandlerTest extends MediaWikiIntegrationTestCase {
);
}
private static function assertWellFormedOAS( array $spec ) {
$requiredTop = [
'openapi',
'info',
'servers',
'paths',
'components'
];
foreach ( $requiredTop as $key ) {
Assert::assertArrayHasKey( $key, $spec );
}
Assert::assertSame( '3.0.0', $spec['openapi'] );
$requiredInfo = [
'title',
'version',
];
foreach ( $requiredInfo as $key ) {
Assert::assertArrayHasKey( $key, $spec['info'] );
}
Assert::assertNotEmpty( $spec['servers'] );
private function assertWellFormedOAS( array $spec ) {
$this->assertMatchesJsonSchema(
__DIR__ . '/data/OpenApi-3.0.json',
$spec
);
}
private static function assertContainsRecursive(
@ -240,8 +222,8 @@ class ModuleSpecHandlerTest extends MediaWikiIntegrationTestCase {
$data = json_decode( (string)$response->getBody(), true );
$this->assertIsArray( $data, 'Body must be a JSON array' );
self::assertWellFormedOAS( $data );
self::assertContainsRecursive( $expected, $data );
$this->assertWellFormedOAS( $data );
$this->assertContainsRecursive( $expected, $data );
}
public static function newFooBarHandler() {

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,5 @@
<?php
use JsonSchema\Constraints\Constraint;
use JsonSchema\Validator as JsonValidator;
use MediaWiki\Config\HashConfig;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\RequestContext;
@ -41,6 +39,7 @@ use Wikimedia\TestingAccessWrapper;
*/
class RestStructureTest extends MediaWikiIntegrationTestCase {
use DummyServicesTrait;
use JsonSchemaAssertionTrait;
/** @var ?Router */
private $router = null;
@ -207,8 +206,7 @@ class RestStructureTest extends MediaWikiIntegrationTestCase {
if ( isset( $settings[ ArrayDef::PARAM_SCHEMA ] ) ) {
try {
self::validateSchema( $settings[ ArrayDef::PARAM_SCHEMA ] );
$this->addToAssertionCount( 1 );
$this->assertValidJsonSchema( $settings[ ArrayDef::PARAM_SCHEMA ] );
} catch ( LogicException $e ) {
$this->fail( "Invalid JSON schema for parameter {$settings['name']}: " . $e->getMessage() );
}
@ -358,28 +356,33 @@ class RestStructureTest extends MediaWikiIntegrationTestCase {
}
}
/**
* Validate a JSON schema.
*
* @param array $schema The schema to validate.
* @throws LogicException if the schema is invalid
*/
public static function validateSchema( array $schema ): void {
$validator = new JsonValidator();
// Load the draft-04 schema from the local file
$draft04Schema = json_decode( file_get_contents( __DIR__ . '/../../../vendor/justinrainbow/json-schema/dist/schema/json-schema-draft-04.json' ) );
public function provideModuleDefinitionFiles() {
$conf = MediaWikiServices::getInstance()->getMainConfig();
$entryPoint = TestingAccessWrapper::newFromClass( EntryPoint::class );
$routeFiles = $entryPoint->getRouteFiles( $conf );
// Validate the schema itself against the meta-schema
$validator->validate( $schema, $draft04Schema, Constraint::CHECK_MODE_TYPE_CAST );
if ( !$validator->isValid() ) {
$errors = $validator->getErrors();
$messages = array_map( static function ( $error ) {
return sprintf( "[%s] %s", $error['property'], $error['message'] );
}, $errors );
throw new LogicException( "Invalid JSON schema: " . implode( "; ", $messages ) );
foreach ( $routeFiles as $file ) {
$moduleSpec = self::loadJsonData( $file );
if ( !isset( $moduleSpec->mwapi ) ) {
// old-school flat route file, skip
continue;
}
yield $file => [ $moduleSpec ];
}
}
/**
* @dataProvider provideModuleDefinitionFiles
*/
public function testModuleDefinitionFiles( stdClass $moduleSpec ) {
$schemaFile = MW_INSTALL_PATH . '/docs/rest/mwapi-1.0.json';
$resolve = [
'https://spec.openapis.org/oas/3.0/schema/2021-09-28#' =>
__DIR__ . '/../integration/includes/Rest/Handler/data/OpenApi-3.0.json',
];
$this->assertMatchesJsonSchema( $schemaFile, $moduleSpec, $resolve );
}
}

View file

@ -0,0 +1,56 @@
<?php
use PHPUnit\Framework\AssertionFailedError;
/**
* @covers JsonSchemaAssertionTrait
* @group MediaWikiIntegrationTestCaseTest
*/
class JsonSchemaAssertionTraitTest extends MediaWikiUnitTestCase {
use JsonSchemaAssertionTrait;
public static function provideValidJson() {
$dir = __DIR__ . '/json';
yield [ "$dir/valid1.json", "$dir/schema1.json" ];
yield [ "$dir/valid2.json", "$dir/schema2.json" ];
}
/**
* @dataProvider provideValidJson
*/
public function testAssertMatchesJsonSchema_valid( $dataFile, $schemaFile ) {
$jsonString = file_get_contents( $dataFile );
$dir = __DIR__ . '/json';
$this->assertMatchesJsonSchema( $schemaFile, $jsonString, [
'https://www.mediawiki.org/test-schema/test1#' => "$dir/schema1.json",
'https://www.mediawiki.org/test-schema/test2#' => "$dir/schema2.json",
'https://www.mediawiki.org/test-schema/test3#' => "$dir/schema3.json",
] );
}
public static function provideInvalidJson() {
$dir = __DIR__ . '/json';
foreach ( glob( __DIR__ . '/json/invalid*.json' ) as $file ) {
yield $file => [ $file, "$dir/schema1.json" ];
}
}
/**
* @dataProvider provideInvalidJson
*/
public function testAssertMatchesJsonSchema_invalid( $dataFile, $schemaFile ) {
$dir = __DIR__ . '/json';
$jsonString = file_get_contents( $dataFile );
$this->expectException( AssertionFailedError::class );
$this->assertMatchesJsonSchema( $schemaFile, $jsonString,
[
'https://www.mediawiki.org/test-schema/test1#' => "$dir/schema1.json",
'https://www.mediawiki.org/test-schema/test2#' => "$dir/schema2.json",
'https://www.mediawiki.org/test-schema/test3#' => "$dir/schema3.json",
]
);
}
}

View file

@ -0,0 +1,3 @@
{
'test' = 'this is not json',
}

View file

@ -0,0 +1,4 @@
{
"$schema": "https://www.mediawiki.org/test-schema/test1",
"context": "MISSING USER"
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://www.mediawiki.org/test-schema/test1",
"context": "test",
"user": {
"name": [ "NOT A STRING" ],
"contact": {
"email": "jsmith@example.test",
"phone": {
"country-code": "++49",
"number": "12345678"
}
}
}
}

View file

@ -0,0 +1,13 @@
{
"$schema": "https://www.mediawiki.org/test-schema/test1",
"context": "test",
"user": {
"name": "jasmin",
"contact": {
"email": "jsmith@example.test",
"phone": {
"number": "MISSING COUNTRY CODE"
}
}
}
}

View file

@ -0,0 +1,14 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"$id": "https://www.mediawiki.org/test-schema/test1",
"type": "object",
"required": [ "context", "user" ],
"properties": {
"user": { "$ref": "https://www.mediawiki.org/test-schema/test2#/definitions/User" },
"context": { "$ref": "#/definitions/Context" }
},
"definitions": {
"Context": { "type": "string" }
},
"additionalProperties": false
}

View file

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"$id": "https://www.mediawiki.org/test-schema/test2",
"definitions": {
"User": {
"required": [ "name" ],
"type": "object",
"properties": {
"name": {
"type": "string"
},
"contact": {
"$ref": "#/definitions/Contact"
}
}
},
"Contact": {
"$ref": "https://www.mediawiki.org/test-schema/test3#/definitions/Contact"
}
}
}

View file

@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"$id": "https://www.mediawiki.org/test-schema/test3",
"definitions": {
"Contact": {
"properties": {
"email": { "type": "string" },
"phone": { "$ref": "#/definitions/Phone" }
},
"additionalProperties": false
},
"Phone": {
"required": [ "country-code", "number" ],
"properties": {
"country-code": { "type": "string" },
"number": { "type": "string" }
}
}
}
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://www.mediawiki.org/test-schema/test1",
"context": "test",
"user": {
"name": "jasmin",
"contact": {
"email": "jsmith@example.test",
"phone": {
"country-code": "++49",
"number": "12345678"
}
}
}
}

View file

@ -0,0 +1,11 @@
{
"$schema": "https://www.mediawiki.org/test-schema/test2",
"context": "test",
"contact": {
"email": "jsmith@example.test",
"phone": {
"country-code": "++49",
"number": "12345678"
}
}
}