wiki.techinc.nl/tests/phpunit/unit/includes/Rest/Module/RouteFileModuleTest.php
daniel 68dc4845b5 REST: fix metrics keys
In Iebcde4645d472d2 I broke the way we generate metrics keys from
endpoint paths. Instead of using the declared paths with placeholders,
we were recording the actual paths, resulting in an explosion of metrics
keys.

This moves metrics logging from Router into Module, where the declared
path is available. This patch also introduces regression tests for the
issue.

Bug: T365111
Change-Id: I2c9ddfe6e28aaecd313356894f17033e2db59073
2024-05-21 17:34:59 +00:00

272 lines
9.5 KiB
PHP

<?php
namespace MediaWiki\Tests\Rest\Module;
use GuzzleHttp\Psr7\Uri;
use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
use MediaWiki\MainConfigNames;
use MediaWiki\Rest\BasicAccess\StaticBasicAuthorizer;
use MediaWiki\Rest\Module\Module;
use MediaWiki\Rest\Module\RouteFileModule;
use MediaWiki\Rest\Reporter\ErrorReporter;
use MediaWiki\Rest\RequestData;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Rest\Validator\Validator;
use MediaWiki\Tests\Rest\RestTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use PHPUnit\Framework\MockObject\MockObject;
use RuntimeException;
use Throwable;
use Wikimedia\TestingAccessWrapper;
/**
* @covers \MediaWiki\Rest\Module\RouteFileModule
*/
class RouteFileModuleTest extends \MediaWikiUnitTestCase {
use RestTestTrait;
use DummyServicesTrait;
private const CANONICAL_SERVER = 'https://wiki.example.com';
private const INTERNAL_SERVER = 'http://api.local:8080';
/** @var Throwable[] */
private $reportedErrors = [];
/**
* @param RequestInterface $request
* @param string|null $authError
* @param string[] $extraRoutes
*
* @return RouteFileModule
*/
private function createRouteFileModule(
RequestInterface $request,
$authError = null,
$extraRoutes = []
) {
$routeFiles = [
__DIR__ . '/moduleTestRoutes.json', // intermediate format with meta-data
__DIR__ . '/moduleFlatRoutes.json', // old, flat format
];
/** @var MockObject|ErrorReporter $mockErrorReporter */
$mockErrorReporter = $this->createNoOpMock( ErrorReporter::class, [ 'reportError' ] );
$mockErrorReporter->method( 'reportError' )
->willReturnCallback( function ( $e ) {
$this->reportedErrors[] = $e;
} );
$config = [
MainConfigNames::CanonicalServer => self::CANONICAL_SERVER,
MainConfigNames::InternalServer => self::INTERNAL_SERVER,
MainConfigNames::RestPath => '/rest',
];
$auth = new StaticBasicAuthorizer( $authError );
$objectFactory = $this->getDummyObjectFactory();
$authority = $this->mockAnonUltimateAuthority();
$validator = new Validator( $objectFactory, $request, $authority );
$router = $this->newRouter( [
'routeFiles' => [],
'request' => $request,
'config' => $config,
'errorReporter' => $mockErrorReporter,
'basicAuth' => $auth,
'validator' => $validator
] );
$module = new RouteFileModule(
$routeFiles,
$extraRoutes,
$router,
'mock.v1',
new ResponseFactory( [] ),
$auth,
$objectFactory,
$validator,
$mockErrorReporter
);
return $module;
}
private function createMockStats( string $method, ...$with ): StatsdDataFactoryInterface {
$stats = $this->createNoOpMock( StatsdDataFactoryInterface::class, [ $method ] );
$stats->expects( $this->atLeastOnce() )->method( $method )->with( ...$with );
return $stats;
}
public function testWrongMethod() {
$request = new RequestData( [
'uri' => new Uri( '/rest/mock.v1/ModuleTest/hello' ),
'method' => 'TRACE'
] );
$module = $this->createRouteFileModule( $request );
$response = $module->execute( '/ModuleTest/hello', $request );
$this->assertSame( 405, $response->getStatusCode() );
$this->assertSame( 'Method Not Allowed', $response->getReasonPhrase() );
$this->assertSame( 'HEAD, GET', $response->getHeaderLine( 'Allow' ) );
}
public function testHeadToGet() {
$request = new RequestData( [
'uri' => new Uri( '/rest/mock.v1/ModuleTest/hello' ),
'method' => 'HEAD'
] );
$module = $this->createRouteFileModule( $request );
$response = $module->execute( '/ModuleTest/hello', $request );
$this->assertSame( 200, $response->getStatusCode() );
}
public function testFlatRouteFile() {
$request = new RequestData( [
'uri' => new Uri( '/rest/foobar/ModuleTest/greetings/you' ),
'method' => 'HEAD'
] );
$module = $this->createRouteFileModule( $request );
$module->setStats( $this->createMockStats(
'timing',
'rest_api_latency._mock_v1_foobar_ModuleTest_greetings_-name-.HEAD.200',
$this->greaterThan( 0 )
) );
$response = $module->execute( '/foobar/ModuleTest/greetings/you', $request );
$this->assertSame( 200, $response->getStatusCode() );
}
public function testNoMatch() {
$request = new RequestData( [ 'uri' => new Uri( '/rest/mock.v1/Bogus' ) ] );
$module = $this->createRouteFileModule( $request );
$response = $module->execute( '/Bogus', $request );
$this->assertSame( 404, $response->getStatusCode() );
// TODO: add more information to the response body and test for its presence here
}
public function testHttpException() {
$request = new RequestData( [ 'uri' => new Uri( '/rest/mock.v1/ModuleTest/throw' ) ] );
$module = $this->createRouteFileModule( $request );
$module->setStats( $this->createMockStats(
'increment',
'rest_api_errors._mock_v1_ModuleTest_throw.GET.555'
) );
$response = $module->execute( '/ModuleTest/throw', $request );
$this->assertSame( 555, $response->getStatusCode() );
$body = $response->getBody();
$body->rewind();
$data = json_decode( $body->getContents(), true );
$this->assertSame( 'Mock error', $data['message'] );
}
public function testFatalException() {
$request = new RequestData( [ 'uri' => new Uri( '/rest/mock.v1/ModuleTest/fatal' ) ] );
$module = $this->createRouteFileModule( $request );
$response = $module->execute( '/ModuleTest/fatal', $request );
$this->assertSame( 500, $response->getStatusCode() );
$body = $response->getBody();
$body->rewind();
$data = json_decode( $body->getContents(), true );
$this->assertStringContainsString( 'RuntimeException', $data['message'] );
$this->assertNotEmpty( $this->reportedErrors );
$this->assertInstanceOf( RuntimeException::class, $this->reportedErrors[0] );
}
public function testRedirectException() {
$request = new RequestData( [ 'uri' => new Uri( '/rest/mock.v1/ModuleTest/throwRedirect' ) ] );
$module = $this->createRouteFileModule( $request );
$response = $module->execute( '/ModuleTest/throwRedirect', $request );
$this->assertSame( 301, $response->getStatusCode() );
$this->assertSame( 'http://example.com', $response->getHeaderLine( 'Location' ) );
}
public function testResponseException() {
$request = new RequestData( [ 'uri' => new Uri( '/rest/mock.v1/ModuleTest/throwWrapped' ) ] );
$module = $this->createRouteFileModule( $request );
$response = $module->execute( '/ModuleTest/throwWrapped', $request );
$this->assertSame( 200, $response->getStatusCode() );
}
public function testBasicAccess() {
// Using the throwing handler is a way to assert that the handler is not executed
$request = new RequestData( [ 'uri' => new Uri( '/rest/mock.v1/ModuleTest/throw' ) ] );
$module = $this->createRouteFileModule( $request, 'test-error', [] );
$response = $module->execute( '/ModuleTest/throw', $request );
$this->assertSame( 403, $response->getStatusCode() );
$body = $response->getBody();
$body->rewind();
$data = json_decode( $body->getContents(), true );
$this->assertSame( 'test-error', $data['error'] );
}
public function testAdditionalEndpoints() {
$request = new RequestData( [
'uri' => new Uri( '/rest/mock.v1/ModuleTest/hello-again' )
] );
$module = $this->createRouteFileModule(
$request,
null,
[ [
'path' => '/ModuleTest/hello-again',
'class' => 'MediaWiki\\Tests\\Rest\\Handler\\HelloHandler'
] ]
);
$response = $module->execute( '/ModuleTest/hello-again', $request );
$this->assertSame( 200, $response->getStatusCode() );
}
public static function provideGetRouteUrl() {
yield 'empty' => [ '', '', [], [] ];
yield 'simple route' => [ '/foo/bar', '/foo/bar' ];
yield 'simple route with query' =>
[ '/foo/bar', '/foo/bar?x=1&y=2', [ 'x' => '1', 'y' => '2' ] ];
yield 'simple route with strange query chars' =>
[ '/foo+bar', '/foo+bar?x=%23&y=%25&z=%2B', [ 'x' => '#', 'y' => '%', 'z' => '+' ] ];
yield 'route with simple path params' =>
[ '/foo/{test}/baz', '/foo/bar/baz', [], [ 'test' => 'bar' ] ];
yield 'route with strange path params' =>
[ '/foo/{test}/baz', '/foo/b%25%2F%2Bz/baz', [], [ 'test' => 'b%/+z' ] ];
yield 'space in path does not become a plus' =>
[ '/foo/{test}/baz', '/foo/b%20z/baz', [], [ 'test' => 'b z' ] ];
yield 'route with simple path params and query' =>
[ '/foo/{test}/baz', '/foo/bar/baz?x=1', [ 'x' => '1' ], [ 'test' => 'bar' ] ];
}
public function testCacheData() {
$request = new RequestData( [ 'uri' => new Uri( '/rest/mock.v1/route' ) ] );
$module1 = $this->createRouteFileModule( $request );
$module1wrapper = TestingAccessWrapper::newFromObject( $module1 );
$cacheData = $module1->getCacheData();
// Create a second module
$module2 = $this->createRouteFileModule( $request );
$module2wrapper = TestingAccessWrapper::newFromObject( $module2 );
// Destroy module2's ability to load routes
$module2wrapper->routeFiles = [ '/this/does/not/exist' ];
// Make sure the config hash is set and matches.
$module2wrapper->configHash = $module1wrapper->configHash;
// Check that initFromCacheData() succeeds.
$this->assertTrue( $module2->initFromCacheData( $cacheData ) );
// Check that the matcher tree is deep-equal after initFromCacheData().
$this->assertEquals( $module1wrapper->getMatchers(), $module2wrapper->getMatchers() );
// Invalidate the cache data
$cacheData[ Module::CACHE_CONFIG_HASH_KEY ] = 'foobar';
// Check that initFromCacheData() fails.
$this->assertFalse( $module2->initFromCacheData( $cacheData ) );
// Check that the matcher tree is still deep-equal.
$this->assertEquals( $module1wrapper->getMatchers(), $module2wrapper->getMatchers() );
}
}