Localisation updates from https://translatewiki.net.
[mediawiki.git] / tests / phpunit / structure / RestStructureTest.php
blob5aa870128f2a09e197976c33e2d1ca0128226dff
1 <?php
3 use MediaWiki\Context\RequestContext;
4 use MediaWiki\HookContainer\HookContainer;
5 use MediaWiki\HookContainer\StaticHookRegistry;
6 use MediaWiki\Language\Language;
7 use MediaWiki\MediaWikiServices;
8 use MediaWiki\Message\Message;
9 use MediaWiki\ParamValidator\TypeDef\ArrayDef;
10 use MediaWiki\Permissions\SimpleAuthority;
11 use MediaWiki\Request\WebRequest;
12 use MediaWiki\Rest\CorsUtils;
13 use MediaWiki\Rest\EntryPoint;
14 use MediaWiki\Rest\Handler;
15 use MediaWiki\Rest\PathTemplateMatcher\PathMatcher;
16 use MediaWiki\Rest\RequestData;
17 use MediaWiki\Rest\ResponseFactory;
18 use MediaWiki\Rest\Router;
19 use MediaWiki\Rest\Validator\Validator;
20 use MediaWiki\Session\Session;
21 use MediaWiki\Tests\Unit\DummyServicesTrait;
22 use MediaWiki\Title\Title;
23 use MediaWiki\User\UserIdentityValue;
24 use Wikimedia\Message\MessageValue;
25 use Wikimedia\ObjectCache\EmptyBagOStuff;
26 use Wikimedia\ParamValidator\ParamValidator;
27 use Wikimedia\Stats\StatsFactory;
28 use Wikimedia\TestingAccessWrapper;
30 /**
31 * Checks that all REST Handlers, core and extensions, conform to the conventions:
32 * - parameters in path have correct PARAM_SOURCE
33 * - path parameters not in path are not required
34 * - do not have inconsistencies in the parameter definitions
36 * @coversNothing
37 * @group Database
39 class RestStructureTest extends MediaWikiIntegrationTestCase {
40 use DummyServicesTrait;
41 use JsonSchemaAssertionTrait;
43 private const SPEC_FILES = [
44 'https://spec.openapis.org/oas/3.0/schema/2021-09-28#' =>
45 MW_INSTALL_PATH . '/tests/phpunit/integration/includes/' .
46 'Rest/Handler/data/OpenApi-3.0.json',
48 'http://json-schema.org/draft-04/schema#' =>
49 MW_INSTALL_PATH . '/vendor/justinrainbow/json-schema/dist/' .
50 'schema/json-schema-draft-04.json',
52 'https://www.mediawiki.org/schema/mwapi-1.0#' =>
53 MW_INSTALL_PATH . '/docs/rest/mwapi-1.0.json',
55 'https://www.mediawiki.org/schema/discovery-1.0#' =>
56 MW_INSTALL_PATH . '/docs/rest/discovery-1.0.json',
59 /** @var ?Router */
60 private $router = null;
62 /**
63 * Constructs a fake MediaWikiServices instance for use in data providers.
65 * @return MediaWikiServices
67 private function getFakeServiceContainer(): MediaWikiServices {
68 $realConfig = MediaWikiServices::getInstance()->getMainConfig();
70 $objectFactory = $this->getDummyObjectFactory();
71 $hookContainer = new HookContainer(
72 new StaticHookRegistry(),
73 $objectFactory
76 $services = $this->createNoOpMock(
77 MediaWikiServices::class,
79 'getMainConfig',
80 'getHookContainer',
81 'getObjectFactory',
82 'getLocalServerObjectCache',
83 'getStatsFactory',
86 $services->method( 'getMainConfig' )->willReturn( $realConfig );
87 $services->method( 'getHookContainer' )->willReturn( $hookContainer );
88 $services->method( 'getObjectFactory' )->willReturn( $objectFactory );
89 $services->method( 'getLocalServerObjectCache' )->willReturn( new EmptyBagOStuff() );
90 $services->method( 'getStatsFactory' )->willReturn( StatsFactory::newNull() );
92 return $services;
95 private function getRouterForDataProviders(): Router {
96 static $router = null;
98 if ( !$router ) {
99 $language = $this->createNoOpMock( Language::class, [ 'getCode' ] );
100 $language->method( 'getCode' )->willReturn( 'en' );
102 $title = Title::makeTitle( NS_SPECIAL, 'Badtitle/dummy title for RestStructureTest' );
103 $authority = new SimpleAuthority( new UserIdentityValue( 0, 'Testor' ), [] );
105 $request = $this->createNoOpMock( WebRequest::class, [ 'getSession' ] );
106 $request->method( 'getSession' )->willReturn( $this->createNoOpMock( Session::class ) );
108 $context = $this->createNoOpMock(
109 RequestContext::class,
110 [ 'getLanguage', 'getTitle', 'getAuthority', 'getRequest' ]
112 $context->method( 'getLanguage' )->willReturn( $language );
113 $context->method( 'getTitle' )->willReturn( $title );
114 $context->method( 'getAuthority' )->willReturn( $authority );
115 $context->method( 'getRequest' )->willReturn( $request );
117 $responseFactory = $this->createNoOpMock( ResponseFactory::class );
118 $cors = $this->createNoOpMock( CorsUtils::class );
120 $services = $this->getFakeServiceContainer();
122 // NOTE: createRouter() implements the logic for determining the list of route files to load.
123 $entryPoint = TestingAccessWrapper::newFromClass( EntryPoint::class );
124 $router = $entryPoint->createRouter(
125 $services,
126 $context,
127 new RequestData(),
128 $responseFactory,
129 $cors
133 return $router;
137 * Initialize/fetch the Router instance for testing
138 * @warning Must not be called in data providers!
139 * @return Router
141 private function getTestRouter(): Router {
142 if ( !$this->router ) {
143 $language = $this->createNoOpMock( Language::class, [ 'getCode' ] );
144 $language->method( 'getCode' )->willReturn( 'en' );
146 $title = Title::makeTitle( NS_SPECIAL, 'Badtitle/dummy title for RestStructureTest' );
147 $authority = new SimpleAuthority( new UserIdentityValue( 0, 'Testor' ), [] );
149 $request = $this->createNoOpMock( WebRequest::class, [ 'getSession' ] );
150 $request->method( 'getSession' )->willReturn( $this->createNoOpMock( Session::class ) );
152 $context = $this->createNoOpMock(
153 RequestContext::class,
154 [ 'getLanguage', 'getTitle', 'getAuthority', 'getRequest' ]
156 $context->method( 'getLanguage' )->willReturn( $language );
157 $context->method( 'getTitle' )->willReturn( $title );
158 $context->method( 'getAuthority' )->willReturn( $authority );
159 $context->method( 'getRequest' )->willReturn( $request );
161 $responseFactory = $this->createNoOpMock( ResponseFactory::class );
162 $cors = $this->createNoOpMock( CorsUtils::class );
164 $this->router = EntryPoint::createRouter(
165 $this->getServiceContainer(), $context, new RequestData(), $responseFactory, $cors
168 return $this->router;
172 * @dataProvider provideRoutes
174 public function testPathParameters( string $moduleName, string $method, string $path ): void {
175 $router = $this->getTestRouter();
176 $module = $router->getModule( $moduleName );
178 $request = new RequestData( [ 'method' => $method ] );
179 $handler = $module->getHandlerForPath( $path, $request, false );
181 $params = $handler->getParamSettings();
182 $dataName = $this->dataName();
184 // Test that all parameters in the path exist and are declared as such
185 $matcher = TestingAccessWrapper::newFromObject( new PathMatcher );
186 $pathParams = [];
187 foreach ( explode( '/', $path ) as $part ) {
188 $param = $matcher->getParamName( $part );
189 if ( $param !== false ) {
190 $this->assertArrayHasKey( $param, $params, "Path parameter $param exists" );
191 $this->assertSame( 'path', $params[$param][Handler::PARAM_SOURCE] ?? null,
192 "$dataName: Path parameter {{$param}} must have PARAM_SOURCE = 'path'" );
193 $pathParams[$param] = true;
197 // Test that any path parameters not in the path aren't marked as required
198 foreach ( $params as $param => $settings ) {
199 if ( ( $settings[Handler::PARAM_SOURCE] ?? null ) === 'path' &&
200 !isset( $pathParams[$param] )
202 $this->assertFalse( $settings[ParamValidator::PARAM_REQUIRED] ?? false,
203 "$dataName, parameter $param: PARAM_REQUIRED cannot be true for a path parameter "
204 . 'not in the path'
209 // In case there were no path parameters
210 $this->addToAssertionCount( 1 );
214 * @dataProvider provideRoutes
216 public function testBodyParameters( string $moduleName, string $method, string $path ): void {
217 $router = $this->getTestRouter();
218 $module = $router->getModule( $moduleName );
220 $request = new RequestData( [ 'method' => $method ] );
221 $handler = $module->getHandlerForPath( $path, $request, false );
223 $bodySettings = $handler->getBodyParamSettings();
225 if ( !$bodySettings ) {
226 $this->addToAssertionCount( 1 );
227 return;
230 foreach ( $bodySettings as $settings ) {
231 $this->assertArrayHasKey( Handler::PARAM_SOURCE, $settings );
232 $this->assertSame( 'body', $settings[Handler::PARAM_SOURCE] );
234 if ( isset( $settings[ ArrayDef::PARAM_SCHEMA ] ) ) {
235 try {
236 $this->assertValidJsonSchema( $settings[ ArrayDef::PARAM_SCHEMA ] );
237 } catch ( LogicException $e ) {
238 $this->fail( "Invalid JSON schema for parameter {$settings['name']}: " . $e->getMessage() );
245 * @dataProvider provideRoutes
247 public function testBodyParametersNotInParamSettings( string $moduleName, string $method, string $path ): void {
248 $router = $this->getTestRouter();
249 $module = $router->getModule( $moduleName );
251 $request = new RequestData( [ 'method' => $method ] );
252 $handler = $module->getHandlerForPath( $path, $request, false );
254 $paramSettings = $handler->getParamSettings();
256 if ( !$paramSettings ) {
257 $this->addToAssertionCount( 1 );
258 return;
261 foreach ( $paramSettings as $settings ) {
262 $this->assertArrayHasKey( Handler::PARAM_SOURCE, $settings );
263 $this->assertNotSame( 'body', $settings[Handler::PARAM_SOURCE] );
267 public function provideModules(): Iterator {
268 $router = $this->getRouterForDataProviders();
270 foreach ( $router->getModuleIds() as $name ) {
271 yield "Module '$name'" => [ $name ];
275 public function provideRoutes(): Iterator {
276 $router = $this->getRouterForDataProviders();
278 foreach ( $router->getModuleIds() as $moduleName ) {
279 $module = $router->getModule( $moduleName );
281 foreach ( $module->getDefinedPaths() as $path => $methods ) {
283 foreach ( $methods as $method ) {
284 // NOTE: we can't use the $module object directly, since it
285 // may hold references to incorrect service instance.
286 yield "Handler in module '$moduleName' for $method $path"
287 => [ $moduleName, $method, $path ];
294 * @dataProvider provideRoutes
296 public function testParameters( string $moduleName, string $method, string $path ): void {
297 $router = $this->getTestRouter();
298 $module = $router->getModule( $moduleName );
300 $request = new RequestData( [ 'method' => $method ] );
301 $handler = $module->getHandlerForPath( $path, $request, false );
303 $params = $handler->getParamSettings();
304 foreach ( $params as $param => $settings ) {
305 $method = $routeSpec['method'] ?? 'GET';
306 $method = implode( ",", (array)$method );
308 $this->assertParameter( $param, $settings, "Handler {$method} {$path}, parameter $param" );
312 private function assertParameter( string $name, $settings, $msg ) {
313 $router = TestingAccessWrapper::newFromObject( $this->getTestRouter() );
315 $dataName = $this->dataName();
316 $this->assertNotSame( '', $name, "$msg: $dataName: Name cannot be empty" );
318 $paramValidator = TestingAccessWrapper::newFromObject( $router->restValidator )->paramValidator;
319 $ret = $paramValidator->checkSettings( $name, $settings, [ 'source' => 'unspecified' ] );
321 // REST-specific parameters
322 $ret['allowedKeys'][] = Handler::PARAM_SOURCE;
323 $ret['allowedKeys'][] = Handler::PARAM_DESCRIPTION;
324 if ( !in_array( $settings[Handler::PARAM_SOURCE] ?? '', Validator::KNOWN_PARAM_SOURCES, true ) ) {
325 $ret['issues'][Handler::PARAM_SOURCE] = "PARAM_SOURCE must be one of " . implode( ', ', Validator::KNOWN_PARAM_SOURCES );
328 // Check that "array" type is not used in getParamSettings
329 if ( isset( $settings[ParamValidator::PARAM_TYPE] ) && $settings[ParamValidator::PARAM_TYPE] === 'array' ) {
330 $this->fail( "$msg: $dataName: 'array' type is not allowed in getParamSettings" );
333 // Warn about unknown keys. Don't fail, they might be for forward- or back-compat.
334 if ( is_array( $settings ) ) {
335 $keys = array_diff(
336 array_keys( $settings ),
337 $ret['allowedKeys']
339 if ( $keys ) {
340 $this->addWarning(
341 "$msg: $dataName: Unrecognized settings keys were used: " . implode( ', ', $keys )
346 if ( count( $ret['issues'] ) === 1 ) {
347 $this->fail( "$msg: $dataName: Validation failed: " . reset( $ret['issues'] ) );
348 } elseif ( $ret['issues'] ) {
349 $this->fail( "$msg: $dataName: Validation failed:\n* " . implode( "\n* ", $ret['issues'] ) );
352 // Check message existence
353 $done = [];
354 foreach ( $ret['messages'] as $msg ) {
355 // We don't really care about the parameters, so do it simply
356 $key = $msg->getKey();
357 if ( !isset( $done[$key] ) ) {
358 $done[$key] = true;
359 $this->assertTrue( Message::newFromKey( $key )->exists(),
360 "$msg: $dataName: Parameter message $key exists" );
364 $description = $settings[Handler::PARAM_DESCRIPTION] ?? null;
365 if ( $description && !is_string( $description ) ) {
366 $this->assertInstanceOf( MessageValue::class, $description );
367 $this->assertTrue(
368 wfMessage( $description->getKey() )->exists(),
369 'Message key of parameter description should exit: '
370 . $description->getKey()
375 public function testRoutePathAndMethodForDuplicates() {
376 $router = $this->getTestRouter();
377 $routes = [];
379 foreach ( $router->getModuleIds() as $moduleName ) {
380 $module = $router->getModule( $moduleName );
381 $paths = $module->getDefinedPaths();
383 foreach ( $paths as $path => $methods ) {
384 foreach ( $methods as $method ) {
385 // NOTE: we can't use the $module object directly, since it
386 // may hold references to incorrect service instance.
387 $key = "$moduleName: $method $path";
389 $this->assertArrayNotHasKey( $key, $routes, "{$key} already exists in routes" );
390 $routes[$key] = true;
396 public function provideModuleDefinitionFiles() {
397 $conf = MediaWikiServices::getInstance()->getMainConfig();
398 $entryPoint = TestingAccessWrapper::newFromClass( EntryPoint::class );
399 $routeFiles = $entryPoint->getRouteFiles( $conf );
401 foreach ( $routeFiles as $file ) {
402 $moduleSpec = self::loadJsonData( $file );
403 if ( !isset( $moduleSpec->mwapi ) ) {
404 // old-school flat route file, skip
405 continue;
407 yield $file => [ $moduleSpec ];
412 * @dataProvider provideModuleDefinitionFiles
414 public function testModuleDefinitionFiles( stdClass $moduleSpec ) {
415 $schemaFile = MW_INSTALL_PATH . '/docs/rest/mwapi-1.0.json';
417 $this->assertMatchesJsonSchema( $schemaFile, $moduleSpec, self::SPEC_FILES );
421 * @dataProvider provideModules
423 public function testGetModuleDescription( string $moduleName ): void {
424 static $infoSchema = [ '$ref' =>
425 'https://www.mediawiki.org/schema/discovery-1.0#/definitions/Module'
428 $router = $this->getTestRouter();
429 $module = $router->getModule( $moduleName );
430 $info = $module->getModuleDescription();
432 $this->assertMatchesJsonSchema( $infoSchema, $info, self::SPEC_FILES );
436 * @dataProvider provideModules
438 public function testGetOpenApiInfo( string $moduleName ): void {
439 static $infoSchema = [ '$ref' =>
440 'https://spec.openapis.org/oas/3.0/schema/2021-09-28#/definitions/Info'
443 $router = $this->getTestRouter();
444 $module = $router->getModule( $moduleName );
445 $info = $module->getOpenApiInfo();
447 $this->assertMatchesJsonSchema( $infoSchema, $info, self::SPEC_FILES );