diff options
Diffstat (limited to 'www/wiki/tests/phpunit/includes/api')
61 files changed, 19408 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php b/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php new file mode 100644 index 00000000..4bffc742 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php @@ -0,0 +1,1275 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiBase + */ +class ApiBaseTest extends ApiTestCase { + /** + * This covers a variety of stub methods that return a fixed value. + * + * @param string|array $method Name of method, or [ name, params... ] + * @param string $value Expected value + * + * @dataProvider provideStubMethods + */ + public function testStubMethods( $expected, $method, $args = [] ) { + // Some of these are protected + $mock = TestingAccessWrapper::newFromObject( new MockApi() ); + $result = call_user_func_array( [ $mock, $method ], $args ); + $this->assertSame( $expected, $result ); + } + + public function provideStubMethods() { + return [ + [ null, 'getModuleManager' ], + [ null, 'getCustomPrinter' ], + [ [], 'getHelpUrls' ], + // @todo This is actually overriden by MockApi + // [ [], 'getAllowedParams' ], + [ true, 'shouldCheckMaxLag' ], + [ true, 'isReadMode' ], + [ false, 'isWriteMode' ], + [ false, 'mustBePosted' ], + [ false, 'isDeprecated' ], + [ false, 'isInternal' ], + [ false, 'needsToken' ], + [ null, 'getWebUITokenSalt', [ [] ] ], + [ null, 'getConditionalRequestData', [ 'etag' ] ], + [ null, 'dynamicParameterDocumentation' ], + ]; + } + + public function testRequireOnlyOneParameterDefault() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + [ "filename" => "foo.txt", "enablechunks" => false ], + "filename", "enablechunks" + ); + $this->assertTrue( true ); + } + + /** + * @expectedException ApiUsageException + */ + public function testRequireOnlyOneParameterZero() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + [ "filename" => "foo.txt", "enablechunks" => 0 ], + "filename", "enablechunks" + ); + } + + /** + * @expectedException ApiUsageException + */ + public function testRequireOnlyOneParameterTrue() { + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + [ "filename" => "foo.txt", "enablechunks" => true ], + "filename", "enablechunks" + ); + } + + public function testRequireOnlyOneParameterMissing() { + $this->setExpectedException( ApiUsageException::class, + 'One of the parameters "foo" and "bar" is required.' ); + $mock = new MockApi(); + $mock->requireOnlyOneParameter( + [ "filename" => "foo.txt", "enablechunks" => false ], + "foo", "bar" ); + } + + public function testRequireMaxOneParameterZero() { + $mock = new MockApi(); + $mock->requireMaxOneParameter( + [ 'foo' => 'bar', 'baz' => 'quz' ], + 'squirrel' ); + $this->assertTrue( true ); + } + + public function testRequireMaxOneParameterOne() { + $mock = new MockApi(); + $mock->requireMaxOneParameter( + [ 'foo' => 'bar', 'baz' => 'quz' ], + 'foo', 'squirrel' ); + $this->assertTrue( true ); + } + + public function testRequireMaxOneParameterTwo() { + $this->setExpectedException( ApiUsageException::class, + 'The parameters "foo" and "baz" can not be used together.' ); + $mock = new MockApi(); + $mock->requireMaxOneParameter( + [ 'foo' => 'bar', 'baz' => 'quz' ], + 'foo', 'baz' ); + } + + public function testRequireAtLeastOneParameterZero() { + $this->setExpectedException( ApiUsageException::class, + 'At least one of the parameters "foo" and "bar" is required.' ); + $mock = new MockApi(); + $mock->requireAtLeastOneParameter( + [ 'a' => 'b', 'c' => 'd' ], + 'foo', 'bar' ); + } + + public function testRequireAtLeastOneParameterOne() { + $mock = new MockApi(); + $mock->requireAtLeastOneParameter( + [ 'a' => 'b', 'c' => 'd' ], + 'foo', 'a' ); + $this->assertTrue( true ); + } + + public function testRequireAtLeastOneParameterTwo() { + $mock = new MockApi(); + $mock->requireAtLeastOneParameter( + [ 'a' => 'b', 'c' => 'd' ], + 'a', 'c' ); + $this->assertTrue( true ); + } + + public function testGetTitleOrPageIdBadParams() { + $this->setExpectedException( ApiUsageException::class, + 'The parameters "title" and "pageid" can not be used together.' ); + $mock = new MockApi(); + $mock->getTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] ); + } + + public function testGetTitleOrPageIdTitle() { + $mock = new MockApi(); + $result = $mock->getTitleOrPageId( [ 'title' => 'Foo' ] ); + $this->assertInstanceOf( WikiPage::class, $result ); + $this->assertSame( 'Foo', $result->getTitle()->getPrefixedText() ); + } + + public function testGetTitleOrPageIdInvalidTitle() { + $this->setExpectedException( ApiUsageException::class, + 'Bad title "|".' ); + $mock = new MockApi(); + $mock->getTitleOrPageId( [ 'title' => '|' ] ); + } + + public function testGetTitleOrPageIdSpecialTitle() { + $this->setExpectedException( ApiUsageException::class, + "Namespace doesn't allow actual pages." ); + $mock = new MockApi(); + $mock->getTitleOrPageId( [ 'title' => 'Special:RandomPage' ] ); + } + + public function testGetTitleOrPageIdPageId() { + $result = ( new MockApi() )->getTitleOrPageId( + [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] ); + $this->assertInstanceOf( WikiPage::class, $result ); + $this->assertSame( 'UTPage', $result->getTitle()->getPrefixedText() ); + } + + public function testGetTitleOrPageIdInvalidPageId() { + $this->setExpectedException( ApiUsageException::class, + 'There is no page with ID 2147483648.' ); + $mock = new MockApi(); + $mock->getTitleOrPageId( [ 'pageid' => 2147483648 ] ); + } + + public function testGetTitleFromTitleOrPageIdBadParams() { + $this->setExpectedException( ApiUsageException::class, + 'The parameters "title" and "pageid" can not be used together.' ); + $mock = new MockApi(); + $mock->getTitleFromTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] ); + } + + public function testGetTitleFromTitleOrPageIdTitle() { + $mock = new MockApi(); + $result = $mock->getTitleFromTitleOrPageId( [ 'title' => 'Foo' ] ); + $this->assertInstanceOf( Title::class, $result ); + $this->assertSame( 'Foo', $result->getPrefixedText() ); + } + + public function testGetTitleFromTitleOrPageIdInvalidTitle() { + $this->setExpectedException( ApiUsageException::class, + 'Bad title "|".' ); + $mock = new MockApi(); + $mock->getTitleFromTitleOrPageId( [ 'title' => '|' ] ); + } + + public function testGetTitleFromTitleOrPageIdPageId() { + $result = ( new MockApi() )->getTitleFromTitleOrPageId( + [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] ); + $this->assertInstanceOf( Title::class, $result ); + $this->assertSame( 'UTPage', $result->getPrefixedText() ); + } + + public function testGetTitleFromTitleOrPageIdInvalidPageId() { + $this->setExpectedException( ApiUsageException::class, + 'There is no page with ID 298401643.' ); + $mock = new MockApi(); + $mock->getTitleFromTitleOrPageId( [ 'pageid' => 298401643 ] ); + } + + /** + * @dataProvider provideGetParameterFromSettings + * @param string|null $input + * @param array $paramSettings + * @param mixed $expected + * @param array $options Key-value pairs: + * 'parseLimits': true|false + * 'apihighlimits': true|false + * 'internalmode': true|false + * @param string[] $warnings + */ + public function testGetParameterFromSettings( + $input, $paramSettings, $expected, $warnings, $options = [] + ) { + $mock = new MockApi(); + $wrapper = TestingAccessWrapper::newFromObject( $mock ); + + $context = new DerivativeContext( $mock ); + $context->setRequest( new FauxRequest( + $input !== null ? [ 'myParam' => $input ] : [] ) ); + $wrapper->mMainModule = new ApiMain( $context ); + + $parseLimits = isset( $options['parseLimits'] ) ? + $options['parseLimits'] : true; + + if ( !empty( $options['apihighlimits'] ) ) { + $context->setUser( self::$users['sysop']->getUser() ); + } + + if ( isset( $options['internalmode'] ) && !$options['internalmode'] ) { + $mainWrapper = TestingAccessWrapper::newFromObject( $wrapper->mMainModule ); + $mainWrapper->mInternalMode = false; + } + + // If we're testing tags, set up some tags + if ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) && + $paramSettings[ApiBase::PARAM_TYPE] === 'tags' + ) { + ChangeTags::defineTag( 'tag1' ); + ChangeTags::defineTag( 'tag2' ); + } + + if ( $expected instanceof Exception ) { + try { + $wrapper->getParameterFromSettings( 'myParam', $paramSettings, + $parseLimits ); + $this->fail( 'No exception thrown' ); + } catch ( Exception $ex ) { + $this->assertEquals( $expected, $ex ); + } + } else { + $result = $wrapper->getParameterFromSettings( 'myParam', + $paramSettings, $parseLimits ); + if ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) && + $paramSettings[ApiBase::PARAM_TYPE] === 'timestamp' && + $expected === 'now' + ) { + // Allow one second of fuzziness. Make sure the formats are + // correct! + $this->assertRegExp( '/^\d{14}$/', $result ); + $this->assertLessThanOrEqual( 1, + abs( wfTimestamp( TS_UNIX, $result ) - time() ), + "Result $result differs from expected $expected by " . + 'more than one second' ); + } else { + $this->assertSame( $expected, $result ); + } + $actualWarnings = array_map( function ( $warn ) { + return $warn instanceof Message + ? array_merge( [ $warn->getKey() ], $warn->getParams() ) + : $warn; + }, $mock->warnings ); + $this->assertSame( $warnings, $actualWarnings ); + } + + if ( !empty( $paramSettings[ApiBase::PARAM_SENSITIVE] ) || + ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) && + $paramSettings[ApiBase::PARAM_TYPE] === 'password' ) + ) { + $mainWrapper = TestingAccessWrapper::newFromObject( $wrapper->getMain() ); + $this->assertSame( [ 'myParam' ], + $mainWrapper->getSensitiveParams() ); + } + } + + public static function provideGetParameterFromSettings() { + $warnings = [ + [ 'apiwarn-badutf8', 'myParam' ], + ]; + + $c0 = ''; + $enc = ''; + for ( $i = 0; $i < 32; $i++ ) { + $c0 .= chr( $i ); + $enc .= ( $i === 9 || $i === 10 || $i === 13 ) + ? chr( $i ) + : '�'; + } + + $returnArray = [ + 'Basic param' => [ 'bar', null, 'bar', [] ], + 'Basic param, C0 controls' => [ $c0, null, $enc, $warnings ], + 'String param' => [ 'bar', '', 'bar', [] ], + 'String param, defaulted' => [ null, '', '', [] ], + 'String param, empty' => [ '', 'default', '', [] ], + 'String param, required, empty' => [ + '', + [ ApiBase::PARAM_DFLT => 'default', ApiBase::PARAM_REQUIRED => true ], + ApiUsageException::newWithMessage( null, + [ 'apierror-missingparam', 'myParam' ] ), + [] + ], + 'Multi-valued parameter' => [ + 'a|b|c', + [ ApiBase::PARAM_ISMULTI => true ], + [ 'a', 'b', 'c' ], + [] + ], + 'Multi-valued parameter, alternative separator' => [ + "\x1fa|b\x1fc|d", + [ ApiBase::PARAM_ISMULTI => true ], + [ 'a|b', 'c|d' ], + [] + ], + 'Multi-valued parameter, other C0 controls' => [ + $c0, + [ ApiBase::PARAM_ISMULTI => true ], + [ $enc ], + $warnings + ], + 'Multi-valued parameter, other C0 controls (2)' => [ + "\x1f" . $c0, + [ ApiBase::PARAM_ISMULTI => true ], + [ substr( $enc, 0, -3 ), '' ], + $warnings + ], + 'Multi-valued parameter with limits' => [ + 'a|b|c', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ISMULTI_LIMIT1 => 3, + ], + [ 'a', 'b', 'c' ], + [], + ], + 'Multi-valued parameter with exceeded limits' => [ + 'a|b|c', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ISMULTI_LIMIT1 => 2, + ], + [ 'a', 'b' ], + [ [ 'apiwarn-toomanyvalues', 'myParam', 2 ] ], + ], + 'Multi-valued parameter with exceeded limits for non-bot' => [ + 'a|b|c', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ISMULTI_LIMIT1 => 2, + ApiBase::PARAM_ISMULTI_LIMIT2 => 3, + ], + [ 'a', 'b' ], + [ [ 'apiwarn-toomanyvalues', 'myParam', 2 ] ], + ], + 'Multi-valued parameter with non-exceeded limits for bot' => [ + 'a|b|c', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ISMULTI_LIMIT1 => 2, + ApiBase::PARAM_ISMULTI_LIMIT2 => 3, + ], + [ 'a', 'b', 'c' ], + [], + [ 'apihighlimits' => true ], + ], + 'Multi-valued parameter with prohibited duplicates' => [ + 'a|b|a|c', + [ ApiBase::PARAM_ISMULTI => true ], + // Note that the keys are not sequential! This matches + // array_unique, but might be unexpected. + [ 0 => 'a', 1 => 'b', 3 => 'c' ], + [], + ], + 'Multi-valued parameter with allowed duplicates' => [ + 'a|a', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ALLOW_DUPLICATES => true, + ], + [ 'a', 'a' ], + [], + ], + 'Empty boolean param' => [ + '', + [ ApiBase::PARAM_TYPE => 'boolean' ], + true, + [], + ], + 'Boolean param 0' => [ + '0', + [ ApiBase::PARAM_TYPE => 'boolean' ], + true, + [], + ], + 'Boolean param false' => [ + 'false', + [ ApiBase::PARAM_TYPE => 'boolean' ], + true, + [], + ], + 'Boolean multi-param' => [ + 'true|false', + [ + ApiBase::PARAM_TYPE => 'boolean', + ApiBase::PARAM_ISMULTI => true, + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + 'Multi-values not supported for myParam' + ), + [], + ], + 'Empty boolean param with non-false default' => [ + '', + [ + ApiBase::PARAM_TYPE => 'boolean', + ApiBase::PARAM_DFLT => true, + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + "Boolean param myParam's default is set to '1'. " . + 'Boolean parameters must default to false.' ), + [], + ], + 'Deprecated parameter' => [ + 'foo', + [ ApiBase::PARAM_DEPRECATED => true ], + 'foo', + [ [ 'apiwarn-deprecation-parameter', 'myParam' ] ], + ], + 'Deprecated parameter value' => [ + 'a', + [ ApiBase::PARAM_DEPRECATED_VALUES => [ 'a' => true ] ], + 'a', + [ [ 'apiwarn-deprecation-parameter', 'myParam=a' ] ], + ], + 'Multiple deprecated parameter values' => [ + 'a|b|c|d', + [ ApiBase::PARAM_DEPRECATED_VALUES => + [ 'b' => true, 'd' => true ], + ApiBase::PARAM_ISMULTI => true ], + [ 'a', 'b', 'c', 'd' ], + [ + [ 'apiwarn-deprecation-parameter', 'myParam=b' ], + [ 'apiwarn-deprecation-parameter', 'myParam=d' ], + ], + ], + 'Deprecated parameter value with custom warning' => [ + 'a', + [ ApiBase::PARAM_DEPRECATED_VALUES => [ 'a' => 'my-msg' ] ], + 'a', + [ 'my-msg' ], + ], + '"*" when wildcard not allowed' => [ + '*', + [ ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ] ], + [], + [ [ 'apiwarn-unrecognizedvalues', 'myParam', + [ 'list' => [ '*' ], 'type' => 'comma' ], 1 ] ], + ], + 'Wildcard "*"' => [ + '*', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ], + ApiBase::PARAM_ALL => true, + ], + [ 'a', 'b', 'c' ], + [], + ], + 'Wildcard "*" with multiples not allowed' => [ + '*', + [ + ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ], + ApiBase::PARAM_ALL => true, + ], + ApiUsageException::newWithMessage( null, + [ 'apierror-unrecognizedvalue', 'myParam', '*' ], + 'unknown_myParam' ), + [], + ], + 'Wildcard "*" with unrestricted type' => [ + '*', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ALL => true, + ], + [ '*' ], + [], + ], + 'Wildcard "x"' => [ + 'x', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ], + ApiBase::PARAM_ALL => 'x', + ], + [ 'a', 'b', 'c' ], + [], + ], + 'Wildcard conflicting with allowed value' => [ + 'a', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ], + ApiBase::PARAM_ALL => 'a', + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + 'For param myParam, PARAM_ALL collides with a possible ' . + 'value' ), + [], + ], + 'Namespace with wildcard' => [ + '*', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'namespace', + ], + MWNamespace::getValidNamespaces(), + [], + ], + // PARAM_ALL is ignored with namespace types. + 'Namespace with wildcard suppressed' => [ + '*', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'namespace', + ApiBase::PARAM_ALL => false, + ], + MWNamespace::getValidNamespaces(), + [], + ], + 'Namespace with wildcard "x"' => [ + 'x', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'namespace', + ApiBase::PARAM_ALL => 'x', + ], + [], + [ [ 'apiwarn-unrecognizedvalues', 'myParam', + [ 'list' => [ 'x' ], 'type' => 'comma' ], 1 ] ], + ], + 'Password' => [ + 'dDy+G?e?txnr.1:(@[Ru', + [ ApiBase::PARAM_TYPE => 'password' ], + 'dDy+G?e?txnr.1:(@[Ru', + [], + ], + 'Sensitive field' => [ + 'I am fond of pineapples', + [ ApiBase::PARAM_SENSITIVE => true ], + 'I am fond of pineapples', + [], + ], + 'Upload with default' => [ + '', + [ + ApiBase::PARAM_TYPE => 'upload', + ApiBase::PARAM_DFLT => '', + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + "File upload param myParam's default is set to ''. " . + 'File upload parameters may not have a default.' ), + [], + ], + 'Multiple upload' => [ + '', + [ + ApiBase::PARAM_TYPE => 'upload', + ApiBase::PARAM_ISMULTI => true, + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + 'Multi-values not supported for myParam' ), + [], + ], + // @todo Test actual upload + 'Namespace -1' => [ + '-1', + [ ApiBase::PARAM_TYPE => 'namespace' ], + ApiUsageException::newWithMessage( null, + [ 'apierror-unrecognizedvalue', 'myParam', '-1' ], + 'unknown_myParam' ), + [], + ], + 'Extra namespace -1' => [ + '-1', + [ + ApiBase::PARAM_TYPE => 'namespace', + ApiBase::PARAM_EXTRA_NAMESPACES => [ '-1' ], + ], + '-1', + [], + ], + // @todo Test with PARAM_SUBMODULE_MAP unset, need + // getModuleManager() to return something real + 'Nonexistent module' => [ + 'not-a-module-name', + [ + ApiBase::PARAM_TYPE => 'submodule', + ApiBase::PARAM_SUBMODULE_MAP => + [ 'foo' => 'foo', 'bar' => 'foo+bar' ], + ], + ApiUsageException::newWithMessage( + null, + [ + 'apierror-unrecognizedvalue', + 'myParam', + 'not-a-module-name', + ], + 'unknown_myParam' + ), + [], + ], + '\\x1f with multiples not allowed' => [ + "\x1f", + [], + ApiUsageException::newWithMessage( null, + 'apierror-badvalue-notmultivalue', + 'badvalue_notmultivalue' ), + [], + ], + 'Integer with unenforced min' => [ + '-2', + [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => -1, + ], + -1, + [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', -1, + -2 ] ], + ], + 'Integer with enforced min' => [ + '-2', + [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => -1, + ApiBase::PARAM_RANGE_ENFORCE => true, + ], + ApiUsageException::newWithMessage( null, + [ 'apierror-integeroutofrange-belowminimum', 'myParam', + '-1', '-2' ], 'integeroutofrange', + [ 'min' => -1, 'max' => null, 'botMax' => null ] ), + [], + ], + 'Integer with unenforced max (internal mode)' => [ + '8', + [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MAX => 7, + ], + 8, + [], + ], + 'Integer with enforced max (internal mode)' => [ + '8', + [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MAX => 7, + ApiBase::PARAM_RANGE_ENFORCE => true, + ], + 8, + [], + ], + 'Integer with unenforced max (non-internal mode)' => [ + '8', + [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MAX => 7, + ], + 7, + [ [ 'apierror-integeroutofrange-abovemax', 'myParam', 7, 8 ] ], + [ 'internalmode' => false ], + ], + 'Integer with enforced max (non-internal mode)' => [ + '8', + [ + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MAX => 7, + ApiBase::PARAM_RANGE_ENFORCE => true, + ], + ApiUsageException::newWithMessage( + null, + [ 'apierror-integeroutofrange-abovemax', 'myParam', '7', '8' ], + 'integeroutofrange', + [ 'min' => null, 'max' => 7, 'botMax' => 7 ] + ), + [], + [ 'internalmode' => false ], + ], + 'Array of integers' => [ + '3|12|966|-1', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'integer', + ], + [ 3, 12, 966, -1 ], + [], + ], + 'Array of integers with unenforced min/max (internal mode)' => [ + '3|12|966|-1', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => 0, + ApiBase::PARAM_MAX => 100, + ], + [ 3, 12, 966, 0 ], + [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ] ], + ], + 'Array of integers with enforced min/max (internal mode)' => [ + '3|12|966|-1', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => 0, + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_RANGE_ENFORCE => true, + ], + ApiUsageException::newWithMessage( + null, + [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ], + 'integeroutofrange', + [ 'min' => 0, 'max' => 100, 'botMax' => 100 ] + ), + [], + ], + 'Array of integers with unenforced min/max (non-internal mode)' => [ + '3|12|966|-1', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => 0, + ApiBase::PARAM_MAX => 100, + ], + [ 3, 12, 100, 0 ], + [ + [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 966 ], + [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ] + ], + [ 'internalmode' => false ], + ], + 'Array of integers with enforced min/max (non-internal mode)' => [ + '3|12|966|-1', + [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => 0, + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_RANGE_ENFORCE => true, + ], + ApiUsageException::newWithMessage( + null, + [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 966 ], + 'integeroutofrange', + [ 'min' => 0, 'max' => 100, 'botMax' => 100 ] + ), + [], + [ 'internalmode' => false ], + ], + 'Limit with parseLimits false' => [ + '100', + [ ApiBase::PARAM_TYPE => 'limit' ], + '100', + [], + [ 'parseLimits' => false ], + ], + 'Limit with no max' => [ + '100', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX2 => 10, + ApiBase::PARAM_ISMULTI => true, + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + 'MAX1 or MAX2 are not defined for the limit myParam' ), + [], + ], + 'Limit with no max2' => [ + '100', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 10, + ApiBase::PARAM_ISMULTI => true, + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + 'MAX1 or MAX2 are not defined for the limit myParam' ), + [], + ], + 'Limit with multi-value' => [ + '100', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 10, + ApiBase::PARAM_MAX2 => 10, + ApiBase::PARAM_ISMULTI => true, + ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + 'Multi-values not supported for myParam' ), + [], + ], + 'Valid limit' => [ + '100', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 100, + ], + 100, + [], + ], + 'Limit max' => [ + 'max', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 100, + [], + ], + 'Limit max for apihighlimits' => [ + 'max', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 101, + [], + [ 'apihighlimits' => true ], + ], + 'Limit too large (internal mode)' => [ + '101', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 101, + [], + ], + 'Limit okay for apihighlimits (internal mode)' => [ + '101', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 101, + [], + [ 'apihighlimits' => true ], + ], + 'Limit too large for apihighlimits (internal mode)' => [ + '102', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 102, + [], + [ 'apihighlimits' => true ], + ], + 'Limit too large (non-internal mode)' => [ + '101', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 100, + [ [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 101 ] ], + [ 'internalmode' => false ], + ], + 'Limit okay for apihighlimits (non-internal mode)' => [ + '101', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 101, + [], + [ 'internalmode' => false, 'apihighlimits' => true ], + ], + 'Limit too large for apihighlimits (non-internal mode)' => [ + '102', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 101, + ], + 101, + [ [ 'apierror-integeroutofrange-abovebotmax', 'myParam', 101, 102 ] ], + [ 'internalmode' => false, 'apihighlimits' => true ], + ], + 'Limit too small' => [ + '-2', + [ + ApiBase::PARAM_TYPE => 'limit', + ApiBase::PARAM_MIN => -1, + ApiBase::PARAM_MAX => 100, + ApiBase::PARAM_MAX2 => 100, + ], + -1, + [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', -1, + -2 ] ], + ], + 'Timestamp' => [ + wfTimestamp( TS_UNIX, '20211221122112' ), + [ ApiBase::PARAM_TYPE => 'timestamp' ], + '20211221122112', + [], + ], + 'Timestamp 0' => [ + '0', + [ ApiBase::PARAM_TYPE => 'timestamp' ], + // Magic keyword + 'now', + [ [ 'apiwarn-unclearnowtimestamp', 'myParam', '0' ] ], + ], + 'Timestamp empty' => [ + '', + [ ApiBase::PARAM_TYPE => 'timestamp' ], + 'now', + [ [ 'apiwarn-unclearnowtimestamp', 'myParam', '' ] ], + ], + // wfTimestamp() interprets this as Unix time + 'Timestamp 00' => [ + '00', + [ ApiBase::PARAM_TYPE => 'timestamp' ], + '19700101000000', + [], + ], + 'Timestamp now' => [ + 'now', + [ ApiBase::PARAM_TYPE => 'timestamp' ], + 'now', + [], + ], + 'Invalid timestamp' => [ + 'a potato', + [ ApiBase::PARAM_TYPE => 'timestamp' ], + ApiUsageException::newWithMessage( + null, + [ 'apierror-badtimestamp', 'myParam', 'a potato' ], + 'badtimestamp_myParam' + ), + [], + ], + 'Timestamp array' => [ + '100|101', + [ + ApiBase::PARAM_TYPE => 'timestamp', + ApiBase::PARAM_ISMULTI => 1, + ], + [ wfTimestamp( TS_MW, 100 ), wfTimestamp( TS_MW, 101 ) ], + [], + ], + 'User' => [ + 'foo_bar', + [ ApiBase::PARAM_TYPE => 'user' ], + 'Foo bar', + [], + ], + 'Invalid username "|"' => [ + '|', + [ ApiBase::PARAM_TYPE => 'user' ], + ApiUsageException::newWithMessage( null, + [ 'apierror-baduser', 'myParam', '|' ], + 'baduser_myParam' ), + [], + ], + 'Invalid username "300.300.300.300"' => [ + '300.300.300.300', + [ ApiBase::PARAM_TYPE => 'user' ], + ApiUsageException::newWithMessage( null, + [ 'apierror-baduser', 'myParam', '300.300.300.300' ], + 'baduser_myParam' ), + [], + ], + 'IP range as username' => [ + '10.0.0.0/8', + [ ApiBase::PARAM_TYPE => 'user' ], + '10.0.0.0/8', + [], + ], + 'IPv6 as username' => [ + '::1', + [ ApiBase::PARAM_TYPE => 'user' ], + '0:0:0:0:0:0:0:1', + [], + ], + 'Obsolete cloaked usemod IP address as username' => [ + '1.2.3.xxx', + [ ApiBase::PARAM_TYPE => 'user' ], + '1.2.3.xxx', + [], + ], + 'Invalid username containing IP address' => [ + 'This is [not] valid 1.2.3.xxx, ha!', + [ ApiBase::PARAM_TYPE => 'user' ], + ApiUsageException::newWithMessage( + null, + [ 'apierror-baduser', 'myParam', 'This is [not] valid 1.2.3.xxx, ha!' ], + 'baduser_myParam' + ), + [], + ], + 'External username' => [ + 'M>Foo bar', + [ ApiBase::PARAM_TYPE => 'user' ], + 'M>Foo bar', + [], + ], + 'Array of usernames' => [ + 'foo|bar', + [ + ApiBase::PARAM_TYPE => 'user', + ApiBase::PARAM_ISMULTI => true, + ], + [ 'Foo', 'Bar' ], + [], + ], + 'tag' => [ + 'tag1', + [ ApiBase::PARAM_TYPE => 'tags' ], + [ 'tag1' ], + [], + ], + 'Array of one tag' => [ + 'tag1', + [ + ApiBase::PARAM_TYPE => 'tags', + ApiBase::PARAM_ISMULTI => true, + ], + [ 'tag1' ], + [], + ], + 'Array of tags' => [ + 'tag1|tag2', + [ + ApiBase::PARAM_TYPE => 'tags', + ApiBase::PARAM_ISMULTI => true, + ], + [ 'tag1', 'tag2' ], + [], + ], + 'Invalid tag' => [ + 'invalid tag', + [ ApiBase::PARAM_TYPE => 'tags' ], + new ApiUsageException( null, + Status::newFatal( 'tags-apply-not-allowed-one', + 'invalid tag', 1 ) ), + [], + ], + 'Unrecognized type' => [ + 'foo', + [ ApiBase::PARAM_TYPE => 'nonexistenttype' ], + new MWException( + 'Internal error in ApiBase::getParameterFromSettings: ' . + "Param myParam's type is unknown - nonexistenttype" ), + [], + ], + 'Too many bytes' => [ + '1', + [ + ApiBase::PARAM_MAX_BYTES => 0, + ApiBase::PARAM_MAX_CHARS => 0, + ], + ApiUsageException::newWithMessage( null, + [ 'apierror-maxbytes', 'myParam', 0 ] ), + [], + ], + 'Too many chars' => [ + '§§', + [ + ApiBase::PARAM_MAX_BYTES => 4, + ApiBase::PARAM_MAX_CHARS => 1, + ], + ApiUsageException::newWithMessage( null, + [ 'apierror-maxchars', 'myParam', 1 ] ), + [], + ], + 'Omitted required param' => [ + null, + [ ApiBase::PARAM_REQUIRED => true ], + ApiUsageException::newWithMessage( null, + [ 'apierror-missingparam', 'myParam' ] ), + [], + ], + 'Empty multi-value' => [ + '', + [ ApiBase::PARAM_ISMULTI => true ], + [], + [], + ], + 'Multi-value \x1f' => [ + "\x1f", + [ ApiBase::PARAM_ISMULTI => true ], + [], + [], + ], + 'Allowed non-multi-value with "|"' => [ + 'a|b', + [ ApiBase::PARAM_TYPE => [ 'a|b' ] ], + 'a|b', + [], + ], + 'Prohibited multi-value' => [ + 'a|b', + [ ApiBase::PARAM_TYPE => [ 'a', 'b' ] ], + ApiUsageException::newWithMessage( null, + [ + 'apierror-multival-only-one-of', + 'myParam', + Message::listParam( [ '<kbd>a</kbd>', '<kbd>b</kbd>' ] ), + 2 + ], + 'multival_myParam' + ), + [], + ], + ]; + + // The following really just test PHP's string-to-int conversion. + $integerTests = [ + [ '+1', 1 ], + [ '-1', -1 ], + [ '1.5', 1 ], + [ '-1.5', -1 ], + [ '1abc', 1 ], + [ ' 1', 1 ], + [ "\t1", 1, '\t1' ], + [ "\r1", 1, '\r1' ], + [ "\f1", 0, '\f1', 'badutf-8' ], + [ "\n1", 1, '\n1' ], + [ "\v1", 0, '\v1', 'badutf-8' ], + [ "\e1", 0, '\e1', 'badutf-8' ], + [ "\x001", 0, '\x001', 'badutf-8' ], + ]; + + foreach ( $integerTests as $test ) { + $desc = isset( $test[2] ) ? $test[2] : $test[0]; + $warnings = isset( $test[3] ) ? + [ [ 'apiwarn-badutf8', 'myParam' ] ] : []; + $returnArray["\"$desc\" as integer"] = [ + $test[0], + [ ApiBase::PARAM_TYPE => 'integer' ], + $test[1], + $warnings, + ]; + } + + return $returnArray; + } + + public function testErrorArrayToStatus() { + $mock = new MockApi(); + + // Sanity check empty array + $expect = Status::newGood(); + $this->assertEquals( $expect, $mock->errorArrayToStatus( [] ) ); + + // No blocked $user, so no special block handling + $expect = Status::newGood(); + $expect->fatal( 'blockedtext' ); + $expect->fatal( 'autoblockedtext' ); + $expect->fatal( 'systemblockedtext' ); + $expect->fatal( 'mainpage' ); + $expect->fatal( 'parentheses', 'foobar' ); + $this->assertEquals( $expect, $mock->errorArrayToStatus( [ + [ 'blockedtext' ], + [ 'autoblockedtext' ], + [ 'systemblockedtext' ], + 'mainpage', + [ 'parentheses', 'foobar' ], + ] ) ); + + // Has a blocked $user, so special block handling + $user = $this->getMutableTestUser()->getUser(); + $block = new \Block( [ + 'address' => $user->getName(), + 'user' => $user->getID(), + 'by' => $this->getTestSysop()->getUser()->getId(), + 'reason' => __METHOD__, + 'expiry' => time() + 100500, + ] ); + $block->insert(); + $blockinfo = [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $block ) ]; + + $expect = Status::newGood(); + $expect->fatal( ApiMessage::create( 'apierror-blocked', 'blocked', $blockinfo ) ); + $expect->fatal( ApiMessage::create( 'apierror-autoblocked', 'autoblocked', $blockinfo ) ); + $expect->fatal( ApiMessage::create( 'apierror-systemblocked', 'blocked', $blockinfo ) ); + $expect->fatal( 'mainpage' ); + $expect->fatal( 'parentheses', 'foobar' ); + $this->assertEquals( $expect, $mock->errorArrayToStatus( [ + [ 'blockedtext' ], + [ 'autoblockedtext' ], + [ 'systemblockedtext' ], + 'mainpage', + [ 'parentheses', 'foobar' ], + ], $user ) ); + } + + public function testDieStatus() { + $mock = new MockApi(); + + $status = StatusValue::newGood(); + $status->error( 'foo' ); + $status->warning( 'bar' ); + try { + $mock->dieStatus( $status ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'foo' ), 'Exception has "foo"' ); + $this->assertFalse( ApiTestCase::apiExceptionHasCode( $ex, 'bar' ), 'Exception has "bar"' ); + } + + $status = StatusValue::newGood(); + $status->warning( 'foo' ); + $status->warning( 'bar' ); + try { + $mock->dieStatus( $status ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'foo' ), 'Exception has "foo"' ); + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'bar' ), 'Exception has "bar"' ); + } + + $status = StatusValue::newGood(); + $status->setOk( false ); + try { + $mock->dieStatus( $status ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'unknownerror-nocode' ), + 'Exception has "unknownerror-nocode"' ); + } + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php b/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php new file mode 100644 index 00000000..efefc09d --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php @@ -0,0 +1,252 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiBlock + */ +class ApiBlockTest extends ApiTestCase { + protected $mUser = null; + + protected function setUp() { + parent::setUp(); + + $this->mUser = $this->getMutableTestUser()->getUser(); + $this->setMwGlobals( 'wgBlockCIDRLimit', [ + 'IPv4' => 16, + 'IPv6' => 19, + ] ); + } + + protected function tearDown() { + $block = Block::newFromTarget( $this->mUser->getName() ); + if ( !is_null( $block ) ) { + $block->delete(); + } + parent::tearDown(); + } + + protected function getTokens() { + return $this->getTokenList( self::$users['sysop'] ); + } + + /** + * @param array $extraParams Extra API parameters to pass to doApiRequest + * @param User $blocker User to do the blocking, null to pick + * arbitrarily + */ + private function doBlock( array $extraParams = [], User $blocker = null ) { + if ( $blocker === null ) { + $blocker = self::$users['sysop']->getUser(); + } + + $tokens = $this->getTokens(); + + $this->assertNotNull( $this->mUser, 'Sanity check' ); + + $this->assertArrayHasKey( 'blocktoken', $tokens, 'Sanity check' ); + + $params = [ + 'action' => 'block', + 'user' => $this->mUser->getName(), + 'reason' => 'Some reason', + 'token' => $tokens['blocktoken'], + ]; + if ( array_key_exists( 'userid', $extraParams ) ) { + // Make sure we don't have both user and userid + unset( $params['user'] ); + } + $ret = $this->doApiRequest( array_merge( $params, $extraParams ), null, + false, $blocker ); + + $block = Block::newFromTarget( $this->mUser->getName() ); + + $this->assertTrue( !is_null( $block ), 'Block is valid' ); + + $this->assertSame( $this->mUser->getName(), (string)$block->getTarget() ); + $this->assertSame( 'Some reason', $block->mReason ); + + return $ret; + } + + /** + * Block by username + */ + public function testNormalBlock() { + $this->doBlock(); + } + + /** + * Block by user ID + */ + public function testBlockById() { + $this->doBlock( [ 'userid' => $this->mUser->getId() ] ); + } + + /** + * A blocked user can't block + */ + public function testBlockByBlockedUser() { + $this->setExpectedException( ApiUsageException::class, + 'You cannot block or unblock other users because you are yourself blocked.' ); + + $blocked = $this->getMutableTestUser( [ 'sysop' ] )->getUser(); + $block = new Block( [ + 'address' => $blocked->getName(), + 'by' => self::$users['sysop']->getUser()->getId(), + 'reason' => 'Capriciousness', + 'timestamp' => '19370101000000', + 'expiry' => 'infinity', + ] ); + $block->insert(); + + $this->doBlock( [], $blocked ); + } + + public function testBlockOfNonexistentUser() { + $this->setExpectedException( ApiUsageException::class, + 'There is no user by the name "Nonexistent". Check your spelling.' ); + + $this->doBlock( [ 'user' => 'Nonexistent' ] ); + } + + public function testBlockOfNonexistentUserId() { + $id = 948206325; + $this->setExpectedException( ApiUsageException::class, + "There is no user with ID $id." ); + + $this->assertFalse( User::whoIs( $id ), 'Sanity check' ); + + $this->doBlock( [ 'userid' => $id ] ); + } + + public function testBlockWithTag() { + ChangeTags::defineTag( 'custom tag' ); + + $this->doBlock( [ 'tags' => 'custom tag' ] ); + + $dbw = wfGetDB( DB_MASTER ); + $this->assertSame( 'custom tag', $dbw->selectField( + [ 'change_tag', 'logging' ], + 'ct_tag', + [ 'log_type' => 'block' ], + __METHOD__, + [], + [ 'change_tag' => [ 'INNER JOIN', 'ct_log_id = log_id' ] ] + ) ); + } + + public function testBlockWithProhibitedTag() { + $this->setExpectedException( ApiUsageException::class, + 'You do not have permission to apply change tags along with your changes.' ); + + ChangeTags::defineTag( 'custom tag' ); + + $this->setMwGlobals( 'wgRevokePermissions', + [ 'user' => [ 'applychangetags' => true ] ] ); + + $this->doBlock( [ 'tags' => 'custom tag' ] ); + } + + public function testBlockWithHide() { + global $wgGroupPermissions; + $newPermissions = $wgGroupPermissions['sysop']; + $newPermissions['hideuser'] = true; + $this->mergeMwGlobalArrayValue( 'wgGroupPermissions', + [ 'sysop' => $newPermissions ] ); + + $res = $this->doBlock( [ 'hidename' => '' ] ); + + $dbw = wfGetDB( DB_MASTER ); + $this->assertSame( '1', $dbw->selectField( + 'ipblocks', + 'ipb_deleted', + [ 'ipb_id' => $res[0]['block']['id'] ], + __METHOD__ + ) ); + } + + public function testBlockWithProhibitedHide() { + $this->setExpectedException( ApiUsageException::class, + "You don't have permission to hide user names from the block log." ); + + $this->doBlock( [ 'hidename' => '' ] ); + } + + public function testBlockWithEmailBlock() { + $res = $this->doBlock( [ 'noemail' => '' ] ); + + $dbw = wfGetDB( DB_MASTER ); + $this->assertSame( '1', $dbw->selectField( + 'ipblocks', + 'ipb_block_email', + [ 'ipb_id' => $res[0]['block']['id'] ], + __METHOD__ + ) ); + } + + public function testBlockWithProhibitedEmailBlock() { + $this->setExpectedException( ApiUsageException::class, + "You don't have permission to block users from sending email through the wiki." ); + + $this->setMwGlobals( 'wgRevokePermissions', + [ 'sysop' => [ 'blockemail' => true ] ] ); + + $this->doBlock( [ 'noemail' => '' ] ); + } + + public function testBlockWithExpiry() { + $res = $this->doBlock( [ 'expiry' => '1 day' ] ); + + $dbw = wfGetDB( DB_MASTER ); + $expiry = $dbw->selectField( + 'ipblocks', + 'ipb_expiry', + [ 'ipb_id' => $res[0]['block']['id'] ], + __METHOD__ + ); + + // Allow flakiness up to one second + $this->assertLessThanOrEqual( 1, + abs( wfTimestamp( TS_UNIX, $expiry ) - ( time() + 86400 ) ) ); + } + + public function testBlockWithInvalidExpiry() { + $this->setExpectedException( ApiUsageException::class, "Expiry time invalid." ); + + $this->doBlock( [ 'expiry' => '' ] ); + } + + /** + * @expectedException ApiUsageException + * @expectedExceptionMessage The "token" parameter must be set + */ + public function testBlockingActionWithNoToken() { + $this->doApiRequest( + [ + 'action' => 'block', + 'user' => $this->mUser->getName(), + 'reason' => 'Some reason', + ], + null, + false, + self::$users['sysop']->getUser() + ); + } + + public function testRangeBlock() { + $this->mUser = User::newFromName( '128.0.0.0/16', false ); + $this->doBlock(); + } + + /** + * @expectedException ApiUsageException + * @expectedExceptionMessage Range blocks larger than /16 are not allowed. + */ + public function testVeryLargeRangeBlock() { + $this->mUser = User::newFromName( '128.0.0.0/1', false ); + $this->doBlock(); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php b/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php new file mode 100644 index 00000000..f1d95d03 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php @@ -0,0 +1,95 @@ +<?php + +use MediaWiki\Session\Token; + +/** + * @group API + * @group medium + * @covers ApiCheckToken + */ +class ApiCheckTokenTest extends ApiTestCase { + + /** + * Test result of checking previously queried token (should be valid) + */ + public function testCheckTokenValid() { + // Query token which will be checked later + $tokens = $this->doApiRequest( [ + 'action' => 'query', + 'meta' => 'tokens', + ] ); + + $data = $this->doApiRequest( [ + 'action' => 'checktoken', + 'type' => 'csrf', + 'token' => $tokens[0]['query']['tokens']['csrftoken'], + ], $tokens[1]->getSessionArray() ); + + $this->assertEquals( 'valid', $data[0]['checktoken']['result'] ); + $this->assertArrayHasKey( 'generated', $data[0]['checktoken'] ); + } + + /** + * Test result of checking invalid token + */ + public function testCheckTokenInvalid() { + $session = []; + $data = $this->doApiRequest( [ + 'action' => 'checktoken', + 'type' => 'csrf', + 'token' => 'invalid_token', + ], $session ); + + $this->assertEquals( 'invalid', $data[0]['checktoken']['result'] ); + } + + /** + * Test result of checking token with negative max age (should be expired) + */ + public function testCheckTokenExpired() { + // Query token which will be checked later + $tokens = $this->doApiRequest( [ + 'action' => 'query', + 'meta' => 'tokens', + ] ); + + $data = $this->doApiRequest( [ + 'action' => 'checktoken', + 'type' => 'csrf', + 'token' => $tokens[0]['query']['tokens']['csrftoken'], + 'maxtokenage' => -1, + ], $tokens[1]->getSessionArray() ); + + $this->assertEquals( 'expired', $data[0]['checktoken']['result'] ); + $this->assertArrayHasKey( 'generated', $data[0]['checktoken'] ); + } + + /** + * Test if using token with incorrect suffix will produce a warning + */ + public function testCheckTokenSuffixWarning() { + // Query token which will be checked later + $tokens = $this->doApiRequest( [ + 'action' => 'query', + 'meta' => 'tokens', + ] ); + + // Get token and change the suffix + $token = $tokens[0]['query']['tokens']['csrftoken']; + $token = substr( $token, 0, -strlen( Token::SUFFIX ) ) . urldecode( Token::SUFFIX ); + + $data = $this->doApiRequest( [ + 'action' => 'checktoken', + 'type' => 'csrf', + 'token' => $token, + 'errorformat' => 'raw', + ], $tokens[1]->getSessionArray() ); + + $this->assertEquals( 'invalid', $data[0]['checktoken']['result'] ); + $this->assertArrayHasKey( 'warnings', $data[0] ); + $this->assertCount( 1, $data[0]['warnings'] ); + $this->assertEquals( 'checktoken', $data[0]['warnings'][0]['module'] ); + $this->assertEquals( 'checktoken-percentencoding', $data[0]['warnings'][0]['code'] ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php b/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php new file mode 100644 index 00000000..5b124074 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php @@ -0,0 +1,24 @@ +<?php + +/** + * @group API + * @group medium + * @covers ApiClearHasMsg + */ +class ApiClearHasMsgTest extends ApiTestCase { + + /** + * Test clearing hasmsg flag for current user + */ + public function testClearFlag() { + $user = self::$users['sysop']->getUser(); + $user->setNewtalk( true ); + $this->assertTrue( $user->getNewtalk(), 'sanity check' ); + + $data = $this->doApiRequest( [ 'action' => 'clearhasmsg' ], [] ); + + $this->assertEquals( 'success', $data[0]['clearhasmsg'] ); + $this->assertFalse( $user->getNewtalk() ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php b/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php new file mode 100644 index 00000000..ea13a0d3 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php @@ -0,0 +1,653 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiComparePages + */ +class ApiComparePagesTest extends ApiTestCase { + + protected static $repl = []; + + protected function setUp() { + parent::setUp(); + + // Set $wgExternalDiffEngine to something bogus to try to force use of + // the PHP engine rather than wikidiff2. + $this->setMwGlobals( [ + 'wgExternalDiffEngine' => '/dev/null', + ] ); + } + + protected function addPage( $page, $text, $model = CONTENT_MODEL_WIKITEXT ) { + $title = Title::newFromText( 'ApiComparePagesTest ' . $page ); + $content = ContentHandler::makeContent( $text, $title, $model ); + + $page = WikiPage::factory( $title ); + $user = static::getTestSysop()->getUser(); + $status = $page->doEditContent( + $content, 'Test for ApiComparePagesTest: ' . $text, 0, false, $user + ); + if ( !$status->isOK() ) { + $this->fail( "Failed to create $title: " . $status->getWikiText( false, false, 'en' ) ); + } + return $status->value['revision']->getId(); + } + + public function addDBDataOnce() { + $user = static::getTestSysop()->getUser(); + self::$repl['creator'] = $user->getName(); + self::$repl['creatorid'] = $user->getId(); + + self::$repl['revA1'] = $this->addPage( 'A', 'A 1' ); + self::$repl['revA2'] = $this->addPage( 'A', 'A 2' ); + self::$repl['revA3'] = $this->addPage( 'A', 'A 3' ); + self::$repl['revA4'] = $this->addPage( 'A', 'A 4' ); + self::$repl['pageA'] = Title::newFromText( 'ApiComparePagesTest A' )->getArticleId(); + + self::$repl['revB1'] = $this->addPage( 'B', 'B 1' ); + self::$repl['revB2'] = $this->addPage( 'B', 'B 2' ); + self::$repl['revB3'] = $this->addPage( 'B', 'B 3' ); + self::$repl['revB4'] = $this->addPage( 'B', 'B 4' ); + self::$repl['pageB'] = Title::newFromText( 'ApiComparePagesTest B' )->getArticleId(); + + self::$repl['revC1'] = $this->addPage( 'C', 'C 1' ); + self::$repl['revC2'] = $this->addPage( 'C', 'C 2' ); + self::$repl['revC3'] = $this->addPage( 'C', 'C 3' ); + self::$repl['pageC'] = Title::newFromText( 'ApiComparePagesTest C' )->getArticleId(); + + $id = $this->addPage( 'D', 'D 1' ); + self::$repl['pageD'] = Title::newFromText( 'ApiComparePagesTest D' )->getArticleId(); + wfGetDB( DB_MASTER )->delete( 'revision', [ 'rev_id' => $id ] ); + + self::$repl['revE1'] = $this->addPage( 'E', 'E 1' ); + self::$repl['revE2'] = $this->addPage( 'E', 'E 2' ); + self::$repl['revE3'] = $this->addPage( 'E', 'E 3' ); + self::$repl['revE4'] = $this->addPage( 'E', 'E 4' ); + self::$repl['pageE'] = Title::newFromText( 'ApiComparePagesTest E' )->getArticleId(); + wfGetDB( DB_MASTER )->update( + 'page', [ 'page_latest' => 0 ], [ 'page_id' => self::$repl['pageE'] ] + ); + + self::$repl['revF1'] = $this->addPage( 'F', "== Section 1 ==\nF 1.1\n\n== Section 2 ==\nF 1.2" ); + self::$repl['pageF'] = Title::newFromText( 'ApiComparePagesTest F' )->getArticleId(); + + WikiPage::factory( Title::newFromText( 'ApiComparePagesTest C' ) ) + ->doDeleteArticleReal( 'Test for ApiComparePagesTest' ); + + RevisionDeleter::createList( + 'revision', + RequestContext::getMain(), + Title::newFromText( 'ApiComparePagesTest B' ), + [ self::$repl['revB2'] ] + )->setVisibility( [ + 'value' => [ + Revision::DELETED_TEXT => 1, + Revision::DELETED_USER => 1, + Revision::DELETED_COMMENT => 1, + ], + 'comment' => 'Test for ApiComparePages', + ] ); + + RevisionDeleter::createList( + 'revision', + RequestContext::getMain(), + Title::newFromText( 'ApiComparePagesTest B' ), + [ self::$repl['revB3'] ] + )->setVisibility( [ + 'value' => [ + Revision::DELETED_USER => 1, + Revision::DELETED_COMMENT => 1, + Revision::DELETED_RESTRICTED => 1, + ], + 'comment' => 'Test for ApiComparePages', + ] ); + + Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason + } + + protected function doReplacements( &$value ) { + if ( is_string( $value ) ) { + if ( preg_match( '/^{{REPL:(.+?)}}$/', $value, $m ) ) { + $value = self::$repl[$m[1]]; + } else { + $value = preg_replace_callback( '/{{REPL:(.+?)}}/', function ( $m ) { + return isset( self::$repl[$m[1]] ) ? self::$repl[$m[1]] : $m[0]; + }, $value ); + } + } elseif ( is_array( $value ) || is_object( $value ) ) { + foreach ( $value as &$v ) { + $this->doReplacements( $v ); + } + unset( $v ); + } + } + + /** + * @dataProvider provideDiff + */ + public function testDiff( $params, $expect, $exceptionCode = false, $sysop = false ) { + $this->doReplacements( $params ); + + $params += [ + 'action' => 'compare', + ]; + + $user = $sysop + ? static::getTestSysop()->getUser() + : static::getTestUser()->getUser(); + if ( $exceptionCode ) { + try { + $this->doApiRequest( $params, null, false, $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( $this->apiExceptionHasCode( $ex, $exceptionCode ), + "Exception with code $exceptionCode" ); + } + } else { + $apiResult = $this->doApiRequest( $params, null, false, $user ); + $apiResult = $apiResult[0]; + $this->doReplacements( $expect ); + $this->assertEquals( $expect, $apiResult ); + } + } + + public static function provideDiff() { + // phpcs:disable Generic.Files.LineLength.TooLong + return [ + 'Basic diff, titles' => [ + [ + 'fromtitle' => 'ApiComparePagesTest A', + 'totitle' => 'ApiComparePagesTest B', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA4}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB4}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, page IDs' => [ + [ + 'fromid' => '{{REPL:pageA}}', + 'toid' => '{{REPL:pageB}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA4}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB4}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, revision IDs' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revA3}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageA}}', + 'torevid' => '{{REPL:revA3}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest A', + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>A <del class="diffchange diffchange-inline">2</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>A <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, deleted revision ID as sysop' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revC2}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => 0, + 'torevid' => '{{REPL:revC2}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest C', + 'toarchive' => true, + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">C </ins>2</div></td></tr>' . "\n", + ] + ], + false, true + ], + 'Basic diff, revdel as sysop' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revB2}}', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest A', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB2}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'totexthidden' => true, + 'touserhidden' => true, + 'tocommenthidden' => true, + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>2</div></td></tr>' . "\n", + ] + ], + false, true + ], + 'Basic diff, text' => [ + [ + 'fromtext' => 'From text', + 'fromcontentmodel' => 'wikitext', + 'totext' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel' => 'wikitext', + ], + [ + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, text 2' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text {{subst:PAGENAME}}', + 'tocontentmodel' => 'wikitext', + ], + [ + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, guessed model' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text', + ], + [ + 'warnings' => [ + 'compare' => [ + 'warnings' => 'No content model could be determined, assuming wikitext.', + ], + ], + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text</div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, text with title and PST' => [ + [ + 'fromtext' => 'From text', + 'totitle' => 'Test', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">Test</ins></div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, text with page ID and PST' => [ + [ + 'fromtext' => 'From text', + 'toid' => '{{REPL:pageB}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, text with revision and PST' => [ + [ + 'fromtext' => 'From text', + 'torev' => '{{REPL:revB2}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n", + ] + ], + ], + 'Basic diff, text with deleted revision and PST' => [ + [ + 'fromtext' => 'From text', + 'torev' => '{{REPL:revC2}}', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [ + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest C</ins></div></td></tr>' . "\n", + ] + ], + false, true + ], + 'Basic diff, test with sections' => [ + [ + 'fromtitle' => 'ApiComparePagesTest F', + 'fromsection' => 1, + 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?", + 'tosection' => 2, + ], + [ + 'compare' => [ + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>== Section <del class="diffchange diffchange-inline">1 </del>==</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>== Section <ins class="diffchange diffchange-inline">2 </ins>==</div></td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n", + 'fromid' => '{{REPL:pageF}}', + 'fromrevid' => '{{REPL:revF1}}', + 'fromns' => '0', + 'fromtitle' => 'ApiComparePagesTest F', + ] + ], + ], + 'Diff with all props' => [ + [ + 'fromrev' => '{{REPL:revB1}}', + 'torev' => '{{REPL:revB3}}', + 'totitle' => 'ApiComparePagesTest B', + 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size' + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageB}}', + 'fromrevid' => '{{REPL:revB1}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest B', + 'fromsize' => 3, + 'fromuser' => '{{REPL:creator}}', + 'fromuserid' => '{{REPL:creatorid}}', + 'fromcomment' => 'Test for ApiComparePagesTest: B 1', + 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 1', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB3}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'tosize' => 3, + 'touserhidden' => true, + 'tocommenthidden' => true, + 'tosuppressed' => true, + 'next' => '{{REPL:revB4}}', + 'diffsize' => 391, + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>B <del class="diffchange diffchange-inline">1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n", + ] + ], + ], + 'Diff with all props as sysop' => [ + [ + 'fromrev' => '{{REPL:revB2}}', + 'torev' => '{{REPL:revB3}}', + 'totitle' => 'ApiComparePagesTest B', + 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size' + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageB}}', + 'fromrevid' => '{{REPL:revB2}}', + 'fromns' => 0, + 'fromtitle' => 'ApiComparePagesTest B', + 'fromsize' => 3, + 'fromtexthidden' => true, + 'fromuserhidden' => true, + 'fromuser' => '{{REPL:creator}}', + 'fromuserid' => '{{REPL:creatorid}}', + 'fromcommenthidden' => true, + 'fromcomment' => 'Test for ApiComparePagesTest: B 2', + 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 2', + 'toid' => '{{REPL:pageB}}', + 'torevid' => '{{REPL:revB3}}', + 'tons' => 0, + 'totitle' => 'ApiComparePagesTest B', + 'tosize' => 3, + 'touserhidden' => true, + 'tocommenthidden' => true, + 'tosuppressed' => true, + 'prev' => '{{REPL:revB1}}', + 'next' => '{{REPL:revB4}}', + 'diffsize' => 391, + 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n" + . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n" + . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>B <del class="diffchange diffchange-inline">2</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n", + ] + ], + false, true + ], + 'Relative diff, cur' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torelative' => 'cur', + 'prop' => 'ids', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageA}}', + 'fromrevid' => '{{REPL:revA2}}', + 'toid' => '{{REPL:pageA}}', + 'torevid' => '{{REPL:revA4}}', + ] + ], + ], + 'Relative diff, next' => [ + [ + 'fromrev' => '{{REPL:revE2}}', + 'torelative' => 'next', + 'prop' => 'ids|rel', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageE}}', + 'fromrevid' => '{{REPL:revE2}}', + 'toid' => '{{REPL:pageE}}', + 'torevid' => '{{REPL:revE3}}', + 'prev' => '{{REPL:revE1}}', + 'next' => '{{REPL:revE4}}', + ] + ], + ], + 'Relative diff, prev' => [ + [ + 'fromrev' => '{{REPL:revE3}}', + 'torelative' => 'prev', + 'prop' => 'ids|rel', + ], + [ + 'compare' => [ + 'fromid' => '{{REPL:pageE}}', + 'fromrevid' => '{{REPL:revE2}}', + 'toid' => '{{REPL:pageE}}', + 'torevid' => '{{REPL:revE3}}', + 'prev' => '{{REPL:revE1}}', + 'next' => '{{REPL:revE4}}', + ] + ], + ], + + 'Error, missing title' => [ + [ + 'fromtitle' => 'ApiComparePagesTest X', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'missingtitle', + ], + 'Error, invalid title' => [ + [ + 'fromtitle' => '<bad>', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'invalidtitle', + ], + 'Error, missing page ID' => [ + [ + 'fromid' => 8817900, + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchpageid', + ], + 'Error, page with missing revision' => [ + [ + 'fromtitle' => 'ApiComparePagesTest D', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchrevid', + ], + 'Error, page with no revision' => [ + [ + 'fromtitle' => 'ApiComparePagesTest E', + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchrevid', + ], + 'Error, bad rev ID' => [ + [ + 'fromrev' => 8817900, + 'totitle' => 'ApiComparePagesTest B', + ], + [], + 'nosuchrevid', + ], + 'Error, deleted revision ID, non-sysop' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revC2}}', + ], + [], + 'nosuchrevid', + ], + 'Error, revision-deleted content' => [ + [ + 'fromrev' => '{{REPL:revA2}}', + 'torev' => '{{REPL:revB2}}', + ], + [], + 'missingcontent', + ], + 'Error, text with no title and PST' => [ + [ + 'fromtext' => 'From text', + 'totext' => 'To text {{subst:PAGENAME}}', + 'topst' => true, + ], + [], + 'compare-no-title', + ], + 'Error, test with invalid from section ID' => [ + [ + 'fromtitle' => 'ApiComparePagesTest F', + 'fromsection' => 5, + 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?", + 'tosection' => 2, + ], + [], + 'nosuchfromsection', + ], + 'Error, test with invalid to section ID' => [ + [ + 'fromtitle' => 'ApiComparePagesTest F', + 'fromsection' => 1, + 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?", + 'tosection' => 5, + ], + [], + 'nosuchtosection', + ], + 'Error, Relative diff, no from revision' => [ + [ + 'fromtext' => 'Foo', + 'torelative' => 'cur', + 'prop' => 'ids', + ], + [], + 'compare-relative-to-nothing' + ], + 'Error, Relative diff, cur with no current revision' => [ + [ + 'fromrev' => '{{REPL:revE2}}', + 'torelative' => 'cur', + 'prop' => 'ids', + ], + [], + 'nosuchrevid' + ], + 'Error, Relative diff, next revdeleted' => [ + [ + 'fromrev' => '{{REPL:revB1}}', + 'torelative' => 'next', + 'prop' => 'ids', + ], + [], + 'missingcontent' + ], + 'Error, Relative diff, prev revdeleted' => [ + [ + 'fromrev' => '{{REPL:revB3}}', + 'torelative' => 'prev', + 'prop' => 'ids', + ], + [], + 'missingcontent' + ], + ]; + // phpcs:enable + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php b/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php new file mode 100644 index 00000000..788d120c --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php @@ -0,0 +1,198 @@ +<?php + +/** + * @covers ApiContinuationManager + * @group API + */ +class ApiContinuationManagerTest extends MediaWikiTestCase { + + private static function getManager( $continue, $allModules, $generatedModules ) { + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setRequest( new FauxRequest( [ 'continue' => $continue ] ) ); + $main = new ApiMain( $context ); + return new ApiContinuationManager( $main, $allModules, $generatedModules ); + } + + public function testContinuation() { + $allModules = [ + new MockApiQueryBase( 'mock1' ), + new MockApiQueryBase( 'mock2' ), + new MockApiQueryBase( 'mocklist' ), + ]; + $generator = new MockApiQueryBase( 'generator' ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( ApiMain::class, $manager->getSource() ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'm1continue' => '1|2', + 'continue' => '||mock2', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + 'mocklist' => [ 'mlcontinue' => 2 ], + 'generator' => [ 'gcontinue' => 3 ], + ], $manager->getRawContinuation() ); + + $result = new ApiResult( 0 ); + $manager->setContinuationIntoResult( $result ); + $this->assertSame( [ + 'mlcontinue' => 2, + 'm1continue' => '1|2', + 'continue' => '||mock2', + ], $result->getResultData( 'continue' ) ); + $this->assertSame( null, $result->getResultData( 'batchcomplete' ) ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', [ 3, 4 ] ); + $this->assertSame( [ [ + 'm1continue' => '1|2', + 'continue' => '||mock2|mocklist', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + 'generator' => [ 'gcontinue' => '3|4' ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'gcontinue' => 3, + 'continue' => 'gcontinue||', + ], true ], $manager->getContinuation() ); + $this->assertSame( [ + 'mocklist' => [ 'mlcontinue' => 2 ], + 'generator' => [ 'gcontinue' => 3 ], + ], $manager->getRawContinuation() ); + + $result = new ApiResult( 0 ); + $manager->setContinuationIntoResult( $result ); + $this->assertSame( [ + 'mlcontinue' => 2, + 'gcontinue' => 3, + 'continue' => 'gcontinue||', + ], $result->getResultData( 'continue' ) ); + $this->assertSame( true, $result->getResultData( 'batchcomplete' ) ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 ); + $this->assertSame( [ [ + 'gcontinue' => 3, + 'continue' => 'gcontinue||mocklist', + ], true ], $manager->getContinuation() ); + $this->assertSame( [ + 'generator' => [ 'gcontinue' => 3 ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'm1continue' => '1|2', + 'continue' => '||mock2', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + 'mocklist' => [ 'mlcontinue' => 2 ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] ); + $this->assertSame( [ [ + 'm1continue' => '1|2', + 'continue' => '||mock2|mocklist', + ], false ], $manager->getContinuation() ); + $this->assertSame( [ + 'mock1' => [ 'm1continue' => '1|2' ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 ); + $this->assertSame( [ [ + 'mlcontinue' => 2, + 'continue' => '-||mock1|mock2', + ], true ], $manager->getContinuation() ); + $this->assertSame( [ + 'mocklist' => [ 'mlcontinue' => 2 ], + ], $manager->getRawContinuation() ); + + $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( $allModules, $manager->getRunModules() ); + $this->assertSame( [ [], true ], $manager->getContinuation() ); + $this->assertSame( [], $manager->getRawContinuation() ); + + $manager = self::getManager( '||mock2', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( false, $manager->isGeneratorDone() ); + $this->assertSame( + array_values( array_diff_key( $allModules, [ 1 => 1 ] ) ), + $manager->getRunModules() + ); + + $manager = self::getManager( '-||', $allModules, [ 'mock1', 'mock2' ] ); + $this->assertSame( true, $manager->isGeneratorDone() ); + $this->assertSame( + array_values( array_diff_key( $allModules, [ 0 => 0, 1 => 1 ] ) ), + $manager->getRunModules() + ); + + try { + self::getManager( 'foo', $allModules, [ 'mock1', 'mock2' ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'badcontinue' ), + 'Expected exception' + ); + } + + $manager = self::getManager( + '||mock2', + array_slice( $allModules, 0, 2 ), + [ 'mock1', 'mock2' ] + ); + try { + $manager->addContinueParam( $allModules[1], 'm2continue', 1 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'Module \'mock2\' was not supposed to have been executed, ' . + 'but it was executed anyway', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $manager->addContinueParam( $allModules[2], 'mlcontinue', 1 ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'Module \'mocklist\' called ApiContinuationManager::addContinueParam ' . + 'but was not passed to ApiContinuationManager::__construct', + $ex->getMessage(), + 'Expected exception' + ); + } + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php b/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php new file mode 100644 index 00000000..0f2bcc61 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php @@ -0,0 +1,168 @@ +<?php + +/** + * Tests for MediaWiki api.php?action=delete. + * + * @author Yifei He + * + * @group API + * @group Database + * @group medium + * + * @covers ApiDelete + */ +class ApiDeleteTest extends ApiTestCase { + public function testDelete() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + // create new page + $this->editPage( $name, 'Some text' ); + + // test deletion + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'delete', + 'title' => $name, + ] )[0]; + + $this->assertArrayHasKey( 'delete', $apiResult ); + $this->assertArrayHasKey( 'title', $apiResult['delete'] ); + $this->assertSame( $name, $apiResult['delete']['title'] ); + $this->assertArrayHasKey( 'logid', $apiResult['delete'] ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + + public function testDeleteNonexistent() { + $this->setExpectedException( ApiUsageException::class, + "The page you specified doesn't exist." ); + + $this->doApiRequestWithToken( [ + 'action' => 'delete', + 'title' => 'This page deliberately left nonexistent', + ] ); + } + + public function testDeletionWithoutPermission() { + $this->setExpectedException( ApiUsageException::class, + 'The action you have requested is limited to users in the group:' ); + + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + // create new page + $this->editPage( $name, 'Some text' ); + + // test deletion without permission + try { + $user = new User(); + $apiResult = $this->doApiRequest( [ + 'action' => 'delete', + 'title' => $name, + 'token' => $user->getEditToken(), + ], null, null, $user ); + } finally { + $this->assertTrue( Title::newFromText( $name )->exists() ); + } + } + + public function testDeleteWithTag() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + ChangeTags::defineTag( 'custom tag' ); + + $this->editPage( $name, 'Some text' ); + + $this->doApiRequestWithToken( [ + 'action' => 'delete', + 'title' => $name, + 'tags' => 'custom tag', + ] ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + + $dbw = wfGetDB( DB_MASTER ); + $this->assertSame( 'custom tag', $dbw->selectField( + [ 'change_tag', 'logging' ], + 'ct_tag', + [ + 'log_namespace' => NS_HELP, + 'log_title' => ucfirst( __FUNCTION__ ), + ], + __METHOD__, + [], + [ 'change_tag' => [ 'INNER JOIN', 'ct_log_id = log_id' ] ] + ) ); + } + + public function testDeleteWithoutTagPermission() { + $this->setExpectedException( ApiUsageException::class, + 'You do not have permission to apply change tags along with your changes.' ); + + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + ChangeTags::defineTag( 'custom tag' ); + $this->setMwGlobals( 'wgRevokePermissions', + [ 'user' => [ 'applychangetags' => true ] ] ); + + $this->editPage( $name, 'Some text' ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'delete', + 'title' => $name, + 'tags' => 'custom tag', + ] ); + } finally { + $this->assertTrue( Title::newFromText( $name )->exists() ); + } + } + + public function testDeleteAbortedByHook() { + $this->setExpectedException( ApiUsageException::class, + 'Deletion aborted by hook. It gave no explanation.' ); + + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Some text' ); + + $this->setTemporaryHook( 'ArticleDelete', + function () { + return false; + } + ); + + try { + $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name ] ); + } finally { + $this->assertTrue( Title::newFromText( $name )->exists() ); + } + } + + public function testDeleteWatch() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $user = self::$users['sysop']->getUser(); + + $this->editPage( $name, 'Some text' ); + $this->assertTrue( Title::newFromText( $name )->exists() ); + $this->assertFalse( $user->isWatched( Title::newFromText( $name ) ) ); + + $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name, 'watch' => '' ] ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) ); + } + + public function testDeleteUnwatch() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $user = self::$users['sysop']->getUser(); + + $this->editPage( $name, 'Some text' ); + $this->assertTrue( Title::newFromText( $name )->exists() ); + $user->addWatch( Title::newFromText( $name ) ); + $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) ); + + $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name, 'unwatch' => '' ] ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + $this->assertFalse( $user->isWatched( Title::newFromText( $name ) ) ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php b/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php new file mode 100644 index 00000000..cfdd57b8 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php @@ -0,0 +1,19 @@ +<?php + +/** + * @group API + * @group medium + * + * @covers ApiDisabled + */ +class ApiDisabledTest extends ApiTestCase { + public function testDisabled() { + $this->mergeMwGlobalArrayValue( 'wgAPIModules', + [ 'login' => 'ApiDisabled' ] ); + + $this->setExpectedException( ApiUsageException::class, + 'The "login" module has been disabled.' ); + + $this->doApiRequest( [ 'action' => 'login' ] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php b/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php new file mode 100644 index 00000000..c1963389 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php @@ -0,0 +1,1604 @@ +<?php + +/** + * Tests for MediaWiki api.php?action=edit. + * + * @author Daniel Kinzler + * + * @group API + * @group Database + * @group medium + * + * @covers ApiEditPage + */ +class ApiEditPageTest extends ApiTestCase { + + protected function setUp() { + global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang; + + parent::setUp(); + + $this->setMwGlobals( [ + 'wgExtraNamespaces' => $wgExtraNamespaces, + 'wgNamespaceContentModels' => $wgNamespaceContentModels, + 'wgContentHandlers' => $wgContentHandlers, + 'wgContLang' => $wgContLang, + ] ); + + $wgExtraNamespaces[12312] = 'Dummy'; + $wgExtraNamespaces[12313] = 'Dummy_talk'; + $wgExtraNamespaces[12314] = 'DummyNonText'; + $wgExtraNamespaces[12315] = 'DummyNonText_talk'; + + $wgNamespaceContentModels[12312] = "testing"; + $wgNamespaceContentModels[12314] = "testing-nontext"; + + $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting'; + $wgContentHandlers["testing-nontext"] = 'DummyNonTextContentHandler'; + $wgContentHandlers["testing-serialize-error"] = + 'DummySerializeErrorContentHandler'; + + MWNamespace::clearCaches(); + $wgContLang->resetNamespaces(); # reset namespace cache + } + + protected function tearDown() { + global $wgContLang; + + MWNamespace::clearCaches(); + $wgContLang->resetNamespaces(); # reset namespace cache + + parent::tearDown(); + } + + public function testEdit() { + $name = 'Help:ApiEditPageTest_testEdit'; // assume Help namespace to default to wikitext + + // -- test new page -------------------------------------------- + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ] ); + $apiResult = $apiResult[0]; + + // Validate API result data + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertSame( 'Success', $apiResult['edit']['result'] ); + + $this->assertArrayHasKey( 'new', $apiResult['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] ); + + $this->assertArrayHasKey( 'pageid', $apiResult['edit'] ); + + // -- test existing page, no change ---------------------------- + $data = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ] ); + + $this->assertSame( 'Success', $data[0]['edit']['result'] ); + + $this->assertArrayNotHasKey( 'new', $data[0]['edit'] ); + $this->assertArrayHasKey( 'nochange', $data[0]['edit'] ); + + // -- test existing page, with change -------------------------- + $data = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'different text' + ] ); + + $this->assertSame( 'Success', $data[0]['edit']['result'] ); + + $this->assertArrayNotHasKey( 'new', $data[0]['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $data[0]['edit'] ); + + $this->assertArrayHasKey( 'oldrevid', $data[0]['edit'] ); + $this->assertArrayHasKey( 'newrevid', $data[0]['edit'] ); + $this->assertNotEquals( + $data[0]['edit']['newrevid'], + $data[0]['edit']['oldrevid'], + "revision id should change after edit" + ); + } + + /** + * @return array + */ + public static function provideEditAppend() { + return [ + [ # 0: append + 'foo', 'append', 'bar', "foobar" + ], + [ # 1: prepend + 'foo', 'prepend', 'bar', "barfoo" + ], + [ # 2: append to empty page + '', 'append', 'foo', "foo" + ], + [ # 3: prepend to empty page + '', 'prepend', 'foo', "foo" + ], + [ # 4: append to non-existing page + null, 'append', 'foo', "foo" + ], + [ # 5: prepend to non-existing page + null, 'prepend', 'foo', "foo" + ], + ]; + } + + /** + * @dataProvider provideEditAppend + */ + public function testEditAppend( $text, $op, $append, $expected ) { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditAppend_$count"; + + // -- create page (or not) ----------------------------------------- + if ( $text !== null ) { + list( $re ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => $text, ] ); + + $this->assertSame( 'Success', $re['edit']['result'] ); // sanity + } + + // -- try append/prepend -------------------------------------------- + list( $re ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + $op . 'text' => $append, ] ); + + $this->assertSame( 'Success', $re['edit']['result'] ); + + // -- validate ----------------------------------------------------- + $page = new WikiPage( Title::newFromText( $name ) ); + $content = $page->getContent(); + $this->assertNotNull( $content, 'Page should have been created' ); + + $text = $content->getNativeData(); + + $this->assertSame( $expected, $text ); + } + + /** + * Test editing of sections + */ + public function testEditSection() { + $name = 'Help:ApiEditPageTest_testEditSection'; + $page = WikiPage::factory( Title::newFromText( $name ) ); + $text = "==section 1==\ncontent 1\n==section 2==\ncontent2"; + // Preload the page with some text + $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), 'summary' ); + + list( $re ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'section' => '1', + 'text' => "==section 1==\nnew content 1", + ] ); + $this->assertSame( 'Success', $re['edit']['result'] ); + $newtext = WikiPage::factory( Title::newFromText( $name ) ) + ->getContent( Revision::RAW ) + ->getNativeData(); + $this->assertSame( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext ); + + // Test that we raise a 'nosuchsection' error + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'section' => '9999', + 'text' => 'text', + ] ); + $this->fail( "Should have raised an ApiUsageException" ); + } catch ( ApiUsageException $e ) { + $this->assertTrue( self::apiExceptionHasCode( $e, 'nosuchsection' ) ); + } + } + + /** + * Test action=edit§ion=new + * Run it twice so we test adding a new section on a + * page that doesn't exist (T54830) and one that + * does exist + */ + public function testEditNewSection() { + $name = 'Help:ApiEditPageTest_testEditNewSection'; + + // Test on a page that does not already exist + $this->assertFalse( Title::newFromText( $name )->exists() ); + list( $re ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'section' => 'new', + 'text' => 'test', + 'summary' => 'header', + ] ); + + $this->assertSame( 'Success', $re['edit']['result'] ); + // Check the page text is correct + $text = WikiPage::factory( Title::newFromText( $name ) ) + ->getContent( Revision::RAW ) + ->getNativeData(); + $this->assertSame( "== header ==\n\ntest", $text ); + + // Now on one that does + $this->assertTrue( Title::newFromText( $name )->exists() ); + list( $re2 ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'section' => 'new', + 'text' => 'test', + 'summary' => 'header', + ] ); + + $this->assertSame( 'Success', $re2['edit']['result'] ); + $text = WikiPage::factory( Title::newFromText( $name ) ) + ->getContent( Revision::RAW ) + ->getNativeData(); + $this->assertSame( "== header ==\n\ntest\n\n== header ==\n\ntest", $text ); + } + + /** + * Ensure we can edit through a redirect, if adding a section + */ + public function testEdit_redirect() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEdit_redirect_$count"; + $title = Title::newFromText( $name ); + $page = WikiPage::factory( $title ); + + $rname = "Help:ApiEditPageTest_testEdit_redirect_r$count"; + $rtitle = Title::newFromText( $rname ); + $rpage = WikiPage::factory( $rtitle ); + + // base edit for content + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // base edit for redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $rpage, '20120101000000' ); + + // conflicting edit to redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() ); + $this->forceRevisionDate( $rpage, '20120101020202' ); + + // try to save edit, following the redirect + list( $re, , ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $rname, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + 'section' => 'new', + 'redirect' => true, + ] ); + + $this->assertSame( 'Success', $re['edit']['result'], + "no problems expected when following redirect" ); + } + + /** + * Ensure we cannot edit through a redirect, if attempting to overwrite content + */ + public function testEdit_redirectText() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEdit_redirectText_$count"; + $title = Title::newFromText( $name ); + $page = WikiPage::factory( $title ); + + $rname = "Help:ApiEditPageTest_testEdit_redirectText_r$count"; + $rtitle = Title::newFromText( $rname ); + $rpage = WikiPage::factory( $rtitle ); + + // base edit for content + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // base edit for redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $rpage, '20120101000000' ); + + // conflicting edit to redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() ); + $this->forceRevisionDate( $rpage, '20120101020202' ); + + // try to save edit, following the redirect but without creating a section + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $rname, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + 'redirect' => true, + ] ); + + $this->fail( 'redirect-appendonly error expected' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( self::apiExceptionHasCode( $ex, 'redirect-appendonly' ) ); + } + } + + public function testEditConflict() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditConflict_$count"; + $title = Title::newFromText( $name ); + + $page = WikiPage::factory( $title ); + + // base edit + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // conflicting edit + $page->doEditContent( new WikitextContent( "Foo bar" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() ); + $this->forceRevisionDate( $page, '20120101020202' ); + + // try to save edit, expect conflict + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + ] ); + + $this->fail( 'edit conflict expected' ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( self::apiExceptionHasCode( $ex, 'editconflict' ) ); + } + } + + /** + * Ensure that editing using section=new will prevent simple conflicts + */ + public function testEditConflict_newSection() { + static $count = 0; + $count++; + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditConflict_newSection_$count"; + $title = Title::newFromText( $name ); + + $page = WikiPage::factory( $title ); + + // base edit + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $page, '20120101000000' ); + $baseTime = $page->getRevision()->getTimestamp(); + + // conflicting edit + $page->doEditContent( new WikitextContent( "Foo bar" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() ); + $this->forceRevisionDate( $page, '20120101020202' ); + + // try to save edit, expect no conflict + list( $re, , ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'nix bar!', + 'basetimestamp' => $baseTime, + 'section' => 'new', + ] ); + + $this->assertSame( 'Success', $re['edit']['result'], + "no edit conflict expected here" ); + } + + public function testEditConflict_bug41990() { + static $count = 0; + $count++; + + /* + * T43990: if the target page has a newer revision than the redirect, then editing the + * redirect while specifying 'redirect' and *not* specifying 'basetimestamp' erroneously + * caused an edit conflict to be detected. + */ + + // assume NS_HELP defaults to wikitext + $name = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_$count"; + $title = Title::newFromText( $name ); + $page = WikiPage::factory( $title ); + + $rname = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_r$count"; + $rtitle = Title::newFromText( $rname ); + $rpage = WikiPage::factory( $rtitle ); + + // base edit for content + $page->doEditContent( new WikitextContent( "Foo" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $page, '20120101000000' ); + + // base edit for redirect + $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ), + "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() ); + $this->forceRevisionDate( $rpage, '20120101000000' ); + + // new edit to content + $page->doEditContent( new WikitextContent( "Foo bar" ), + "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() ); + $this->forceRevisionDate( $rpage, '20120101020202' ); + + // try to save edit; should work, following the redirect. + list( $re, , ) = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $rname, + 'text' => 'nix bar!', + 'section' => 'new', + 'redirect' => true, + ] ); + + $this->assertSame( 'Success', $re['edit']['result'], + "no edit conflict expected here" ); + } + + /** + * @param WikiPage $page + * @param string|int $timestamp + */ + protected function forceRevisionDate( WikiPage $page, $timestamp ) { + $dbw = wfGetDB( DB_MASTER ); + + $dbw->update( 'revision', + [ 'rev_timestamp' => $dbw->timestamp( $timestamp ) ], + [ 'rev_id' => $page->getLatest() ] ); + + $page->clear(); + } + + public function testCheckDirectApiEditingDisallowed_forNonTextContent() { + $this->setExpectedException( + ApiUsageException::class, + 'Direct editing via API is not supported for content model ' . + 'testing used by Dummy:ApiEditPageTest_nonTextPageEdit' + ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => 'Dummy:ApiEditPageTest_nonTextPageEdit', + 'text' => '{"animals":["kittens!"]}' + ] ); + } + + public function testSupportsDirectApiEditing_withContentHandlerOverride() { + $name = 'DummyNonText:ApiEditPageTest_testNonTextEdit'; + $data = serialize( 'some bla bla text' ); + + $result = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => $data, + ] ); + + $apiResult = $result[0]; + + // Validate API result data + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertSame( 'Success', $apiResult['edit']['result'] ); + + $this->assertArrayHasKey( 'new', $apiResult['edit'] ); + $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] ); + + $this->assertArrayHasKey( 'pageid', $apiResult['edit'] ); + + // validate resulting revision + $page = WikiPage::factory( Title::newFromText( $name ) ); + $this->assertSame( "testing-nontext", $page->getContentModel() ); + $this->assertSame( $data, $page->getContent()->serialize() ); + } + + /** + * This test verifies that after changing the content model + * of a page, undoing that edit via the API will also + * undo the content model change. + */ + public function testUndoAfterContentModelChange() { + $name = 'Help:' . __FUNCTION__; + $uploader = self::$users['uploader']->getUser(); + $sysop = self::$users['sysop']->getUser(); + + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + ], null, $sysop )[0]; + + // Check success + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertSame( 'Success', $apiResult['edit']['result'] ); + $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] ); + // Content model is wikitext + $this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] ); + + // Convert the page to JSON + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => '{}', + 'contentmodel' => 'json', + ], null, $uploader )[0]; + + // Check success + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertSame( 'Success', $apiResult['edit']['result'] ); + $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] ); + $this->assertSame( 'json', $apiResult['edit']['contentmodel'] ); + + $apiResult = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $apiResult['edit']['newrevid'] + ], null, $sysop )[0]; + + // Check success + $this->assertArrayHasKey( 'edit', $apiResult ); + $this->assertArrayHasKey( 'result', $apiResult['edit'] ); + $this->assertSame( 'Success', $apiResult['edit']['result'] ); + $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] ); + // Check that the contentmodel is back to wikitext now. + $this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] ); + } + + // The tests below are mostly not commented because they do exactly what + // you'd expect from the name. + + public function testCorrectContentFormat() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + 'contentmodel' => 'wikitext', + 'contentformat' => 'text/x-wiki', + ] ); + + $this->assertTrue( Title::newFromText( $name )->exists() ); + } + + public function testUnsupportedContentFormat() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'Unrecognized value for parameter "contentformat": nonexistent format.' ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + 'contentformat' => 'nonexistent format', + ] ); + } finally { + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + } + + public function testMismatchedContentFormat() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The requested format text/plain is not supported for content ' . + "model wikitext used by $name." ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'some text', + 'contentmodel' => 'wikitext', + 'contentformat' => 'text/plain', + ] ); + } finally { + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + } + + public function testUndoToInvalidRev() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $revId = $this->editPage( $name, 'Some text' )->value['revision'] + ->getId(); + $revId++; + + $this->setExpectedException( ApiUsageException::class, + "There is no revision with ID $revId." ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $revId, + ] ); + } + + /** + * Tests what happens if the undo parameter is a valid revision, but + * the undoafter parameter doesn't refer to a revision that exists in the + * database. + */ + public function testUndoAfterToInvalidRev() { + // We can't just pick a large number for undoafter (as in + // testUndoToInvalidRev above), because then MediaWiki will helpfully + // assume we switched around undo and undoafter and we'll test the code + // path for undo being invalid, not undoafter. So instead we delete + // the revision from the database. In real life this case could come + // up if a revision number was skipped, e.g., if two transactions try + // to insert new revision rows at once and the first one to succeed + // gets rolled back. + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $titleObj = Title::newFromText( $name ); + + $revId1 = $this->editPage( $name, '1' )->value['revision']->getId(); + $revId2 = $this->editPage( $name, '2' )->value['revision']->getId(); + $revId3 = $this->editPage( $name, '3' )->value['revision']->getId(); + + // Make the middle revision disappear + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( 'revision', [ 'rev_id' => $revId2 ], __METHOD__ ); + $dbw->update( 'revision', [ 'rev_parent_id' => $revId1 ], + [ 'rev_id' => $revId3 ], __METHOD__ ); + + $this->setExpectedException( ApiUsageException::class, + "There is no revision with ID $revId2." ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $revId3, + 'undoafter' => $revId2, + ] ); + } + + /** + * Tests what happens if the undo parameter is a valid revision, but + * undoafter is hidden (rev_deleted). + */ + public function testUndoAfterToHiddenRev() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $titleObj = Title::newFromText( $name ); + + $this->editPage( $name, '0' ); + + $revId1 = $this->editPage( $name, '1' )->value['revision']->getId(); + + $revId2 = $this->editPage( $name, '2' )->value['revision']->getId(); + + // Hide the middle revision + $list = RevisionDeleter::createList( 'revision', + RequestContext::getMain(), $titleObj, [ $revId1 ] ); + $list->setVisibility( [ + 'value' => [ Revision::DELETED_TEXT => 1 ], + 'comment' => 'Bye-bye', + ] ); + + $this->setExpectedException( ApiUsageException::class, + "There is no revision with ID $revId1." ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $revId2, + 'undoafter' => $revId1, + ] ); + } + + /** + * Test undo when a revision with a higher id has an earlier timestamp. + * This can happen if importing an old revision. + */ + public function testUndoWithSwappedRevisions() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $titleObj = Title::newFromText( $name ); + + $this->editPage( $name, '0' ); + + $revId2 = $this->editPage( $name, '2' )->value['revision']->getId(); + + $revId1 = $this->editPage( $name, '1' )->value['revision']->getId(); + + // Now monkey with the timestamp + $dbw = wfGetDB( DB_MASTER ); + $dbw->update( + 'revision', + [ 'rev_timestamp' => wfTimestamp( TS_MW, time() - 86400 ) ], + [ 'rev_id' => $revId1 ], + __METHOD__ + ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $revId2, + 'undoafter' => $revId1, + ] ); + + $text = ( new WikiPage( $titleObj ) )->getContent()->getNativeData(); + + // This is wrong! It should be 1. But let's test for our incorrect + // behavior for now, so if someone fixes it they'll fix the test as + // well to expect 1. If we disabled the test, it might stay disabled + // even once the bug is fixed, which would be a shame. + $this->assertSame( '2', $text ); + } + + public function testUndoWithConflicts() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The edit could not be undone due to conflicting intermediate edits.' ); + + $this->editPage( $name, '1' ); + + $revId = $this->editPage( $name, '2' )->value['revision']->getId(); + + $this->editPage( $name, '3' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $revId, + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent() + ->getNativeData(); + $this->assertSame( '3', $text ); + } + + /** + * undoafter is supposed to be less than undo. If not, we reverse their + * meaning, so that the two are effectively interchangeable. + */ + public function testReversedUndoAfter() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, '0' ); + $revId1 = $this->editPage( $name, '1' )->value['revision']->getId(); + $revId2 = $this->editPage( $name, '2' )->value['revision']->getId(); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'undo' => $revId1, + 'undoafter' => $revId2, + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent() + ->getNativeData(); + $this->assertSame( '1', $text ); + } + + public function testUndoToRevFromDifferentPage() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( "$name-1", 'Some text' ); + $revId = $this->editPage( "$name-1", 'Some more text' ) + ->value['revision']->getId(); + + $this->editPage( "$name-2", 'Some text' ); + + $this->setExpectedException( ApiUsageException::class, + "r$revId is not a revision of $name-2." ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => "$name-2", + 'undo' => $revId, + ] ); + } + + public function testUndoAfterToRevFromDifferentPage() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $revId1 = $this->editPage( "$name-1", 'Some text' ) + ->value['revision']->getId(); + + $revId2 = $this->editPage( "$name-2", 'Some text' ) + ->value['revision']->getId(); + + $this->setExpectedException( ApiUsageException::class, + "r$revId1 is not a revision of $name-2." ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => "$name-2", + 'undo' => $revId2, + 'undoafter' => $revId1, + ] ); + } + + public function testMd5Text() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'md5' => md5( 'Some text' ), + ] ); + + $this->assertTrue( Title::newFromText( $name )->exists() ); + } + + public function testMd5PrependText() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Some text' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'prependtext' => 'Alert: ', + 'md5' => md5( 'Alert: ' ), + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + $this->assertSame( 'Alert: Some text', $text ); + } + + public function testMd5AppendText() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Some text' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => ' is nice', + 'md5' => md5( ' is nice' ), + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + $this->assertSame( 'Some text is nice', $text ); + } + + public function testMd5PrependAndAppendText() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Some text' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'prependtext' => 'Alert: ', + 'appendtext' => ' is nice', + 'md5' => md5( 'Alert: is nice' ), + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + $this->assertSame( 'Alert: Some text is nice', $text ); + } + + public function testIncorrectMd5Text() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The supplied MD5 hash was incorrect.' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'md5' => md5( '' ), + ] ); + } + + public function testIncorrectMd5PrependText() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The supplied MD5 hash was incorrect.' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'prependtext' => 'Some ', + 'appendtext' => 'text', + 'md5' => md5( 'Some ' ), + ] ); + } + + public function testIncorrectMd5AppendText() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The supplied MD5 hash was incorrect.' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'prependtext' => 'Some ', + 'appendtext' => 'text', + 'md5' => md5( 'text' ), + ] ); + } + + public function testCreateOnly() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The article you tried to create has been created already.' ); + + $this->editPage( $name, 'Some text' ); + $this->assertTrue( Title::newFromText( $name )->exists() ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some more text', + 'createonly' => '', + ] ); + } finally { + // Validate that content was not changed + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + + $this->assertSame( 'Some text', $text ); + } + } + + public function testNoCreate() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + "The page you specified doesn't exist." ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'nocreate' => '', + ] ); + } finally { + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + } + + /** + * Appending/prepending is currently only supported for TextContent. We + * test this right now, and when support is added this test should be + * replaced by tests that the support is correct. + */ + public function testAppendWithNonTextContentHandler() { + $name = 'MediaWiki:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + "Can't append to pages using content model testing-nontext." ); + + $this->setTemporaryHook( 'ContentHandlerDefaultModelFor', + function ( Title $title, &$model ) use ( $name ) { + if ( $title->getPrefixedText() === $name ) { + $model = 'testing-nontext'; + } + return true; + } + ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => 'Some text', + ] ); + } + + public function testAppendInMediaWikiNamespace() { + $name = 'MediaWiki:' . ucfirst( __FUNCTION__ ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => 'Some text', + ] ); + + $this->assertTrue( Title::newFromText( $name )->exists() ); + } + + public function testAppendInMediaWikiNamespaceWithSerializationError() { + $name = 'MediaWiki:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'Content serialization failed: Could not unserialize content' ); + + $this->setTemporaryHook( 'ContentHandlerDefaultModelFor', + function ( Title $title, &$model ) use ( $name ) { + if ( $title->getPrefixedText() === $name ) { + $model = 'testing-serialize-error'; + } + return true; + } + ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => 'Some text', + ] ); + } + + public function testAppendNewSection() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Initial content' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => '== New section ==', + 'section' => 'new', + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + + $this->assertSame( "Initial content\n\n== New section ==", $text ); + } + + public function testAppendNewSectionWithInvalidContentModel() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'Sections are not supported for content model text.' ); + + $this->editPage( $name, 'Initial content' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => '== New section ==', + 'section' => 'new', + 'contentmodel' => 'text', + ] ); + } + + public function testAppendNewSectionWithTitle() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Initial content' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'sectiontitle' => 'My section', + 'appendtext' => 'More content', + 'section' => 'new', + ] ); + + $page = new WikiPage( Title::newFromText( $name ) ); + + $this->assertSame( "Initial content\n\n== My section ==\n\nMore content", + $page->getContent()->getNativeData() ); + $this->assertSame( '/* My section */ new section', + $page->getRevision()->getComment() ); + } + + public function testAppendNewSectionWithSummary() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Initial content' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => 'More content', + 'section' => 'new', + 'summary' => 'Add new section', + ] ); + + $page = new WikiPage( Title::newFromText( $name ) ); + + $this->assertSame( "Initial content\n\n== Add new section ==\n\nMore content", + $page->getContent()->getNativeData() ); + // EditPage actually assumes the summary is the section name here + $this->assertSame( '/* Add new section */ new section', + $page->getRevision()->getComment() ); + } + + public function testAppendNewSectionWithTitleAndSummary() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Initial content' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'sectiontitle' => 'My section', + 'appendtext' => 'More content', + 'section' => 'new', + 'summary' => 'Add new section', + ] ); + + $page = new WikiPage( Title::newFromText( $name ) ); + + $this->assertSame( "Initial content\n\n== My section ==\n\nMore content", + $page->getContent()->getNativeData() ); + $this->assertSame( 'Add new section', + $page->getRevision()->getComment() ); + } + + public function testAppendToSection() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "== Section 1 ==\n\nContent\n\n" . + "== Section 2 ==\n\nFascinating!" ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => ' and more content', + 'section' => '1', + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + + $this->assertSame( "== Section 1 ==\n\nContent and more content\n\n" . + "== Section 2 ==\n\nFascinating!", $text ); + } + + public function testAppendToFirstSection() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "Content\n\n== Section 1 ==\n\nFascinating!" ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => ' and more content', + 'section' => '0', + ] ); + + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + + $this->assertSame( "Content and more content\n\n== Section 1 ==\n\n" . + "Fascinating!", $text ); + } + + public function testAppendToNonexistentSection() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, 'There is no section 1.' ); + + $this->editPage( $name, 'Content' ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'appendtext' => ' and more content', + 'section' => '1', + ] ); + } finally { + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + + $this->assertSame( 'Content', $text ); + } + } + + public function testEditMalformedSection() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The "section" parameter must be a valid section ID or "new".' ); + $this->editPage( $name, 'Content' ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Different content', + 'section' => 'It is unlikely that this is valid', + ] ); + } finally { + $text = ( new WikiPage( Title::newFromText( $name ) ) ) + ->getContent()->getNativeData(); + + $this->assertSame( 'Content', $text ); + } + } + + public function testEditWithStartTimestamp() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $this->setExpectedException( ApiUsageException::class, + 'The page has been deleted since you fetched its timestamp.' ); + + $startTime = MWTimestamp::convert( TS_MW, time() - 1 ); + + $this->editPage( $name, 'Some text' ); + + $pageObj = new WikiPage( Title::newFromText( $name ) ); + $pageObj->doDeleteArticle( 'Bye-bye' ); + + $this->assertFalse( $pageObj->exists() ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Different text', + 'starttimestamp' => $startTime, + ] ); + } finally { + $this->assertFalse( $pageObj->exists() ); + } + } + + public function testEditMinor() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->editPage( $name, 'Some text' ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Different text', + 'minor' => '', + ] ); + + $revisionStore = \MediaWiki\MediaWikiServices::getInstance()->getRevisionStore(); + $revision = $revisionStore->getRevisionByTitle( Title::newFromText( $name ) ); + $this->assertTrue( $revision->isMinor() ); + } + + public function testEditRecreate() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $startTime = MWTimestamp::convert( TS_MW, time() - 1 ); + + $this->editPage( $name, 'Some text' ); + + $pageObj = new WikiPage( Title::newFromText( $name ) ); + $pageObj->doDeleteArticle( 'Bye-bye' ); + + $this->assertFalse( $pageObj->exists() ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Different text', + 'starttimestamp' => $startTime, + 'recreate' => '', + ] ); + + $this->assertTrue( Title::newFromText( $name )->exists() ); + } + + public function testEditWatch() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $user = self::$users['sysop']->getUser(); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'watch' => '', + ] ); + + $this->assertTrue( Title::newFromText( $name )->exists() ); + $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) ); + } + + public function testEditUnwatch() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + $user = self::$users['sysop']->getUser(); + $titleObj = Title::newFromText( $name ); + + $user->addWatch( $titleObj ); + + $this->assertFalse( $titleObj->exists() ); + $this->assertTrue( $user->isWatched( $titleObj ) ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'unwatch' => '', + ] ); + + $this->assertTrue( $titleObj->exists() ); + $this->assertFalse( $user->isWatched( $titleObj ) ); + } + + public function testEditWithTag() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + ChangeTags::defineTag( 'custom tag' ); + + $revId = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'tags' => 'custom tag', + ] )[0]['edit']['newrevid']; + + $dbw = wfGetDB( DB_MASTER ); + $this->assertSame( 'custom tag', $dbw->selectField( + 'change_tag', 'ct_tag', [ 'ct_rev_id' => $revId ], __METHOD__ ) ); + } + + public function testEditWithoutTagPermission() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'You do not have permission to apply change tags along with your changes.' ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + + ChangeTags::defineTag( 'custom tag' ); + $this->setMwGlobals( 'wgRevokePermissions', + [ 'user' => [ 'applychangetags' => true ] ] ); + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'tags' => 'custom tag', + ] ); + } finally { + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + } + + public function testEditAbortedByHook() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The modification you tried to make was aborted by an extension.' ); + + $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' . + 'hook-APIEditBeforeSave-closure)' ); + + $this->setTemporaryHook( 'APIEditBeforeSave', + function () { + return false; + } + ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + ] ); + } finally { + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + } + + public function testEditAbortedByHookWithCustomOutput() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' . + 'hook-APIEditBeforeSave-closure)' ); + + $this->setTemporaryHook( 'APIEditBeforeSave', + function ( $unused1, $unused2, &$r ) { + $r['msg'] = 'Some message'; + return false; + } ); + + $result = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + ] ); + Wikimedia\restoreWarnings(); + + $this->assertSame( [ 'msg' => 'Some message', 'result' => 'Failure' ], + $result[0]['edit'] ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + + public function testEditAbortedByEditPageHookWithResult() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setTemporaryHook( 'EditFilterMergedContent', + function ( $unused1, $unused2, Status $status ) { + $status->apiHookResult = [ 'msg' => 'A message for you!' ]; + return false; + } ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + ] ); + + $this->assertFalse( Title::newFromText( $name )->exists() ); + $this->assertSame( [ 'edit' => [ 'msg' => 'A message for you!', + 'result' => 'Failure' ] ], $res[0] ); + } + + public function testEditAbortedByEditPageHookWithNoResult() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The modification you tried to make was aborted by an extension.' ); + + $this->setTemporaryHook( 'EditFilterMergedContent', + function () { + return false; + } + ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + ] ); + } finally { + $this->assertFalse( Title::newFromText( $name )->exists() ); + } + } + + public function testEditWhileBlocked() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'You have been blocked from editing.' ); + + $block = new Block( [ + 'address' => self::$users['sysop']->getUser()->getName(), + 'by' => self::$users['sysop']->getUser()->getId(), + 'reason' => 'Capriciousness', + 'timestamp' => '19370101000000', + 'expiry' => 'infinity', + ] ); + $block->insert(); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + ] ); + } finally { + $block->delete(); + self::$users['sysop']->getUser()->clearInstanceCache(); + } + } + + public function testEditWhileReadOnly() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The wiki is currently in read-only mode.' ); + + $svc = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode(); + $svc->setReason( "Read-only for testing" ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + ] ); + } finally { + $svc->setReason( false ); + } + } + + public function testCreateImageRedirectAnon() { + $name = 'File:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + "Anonymous users can't create image redirects." ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => '#REDIRECT [[File:Other file.png]]', + ], null, new User() ); + } + + public function testCreateImageRedirectLoggedIn() { + $name = 'File:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + "You don't have permission to create image redirects." ); + + $this->setMwGlobals( 'wgRevokePermissions', + [ 'user' => [ 'upload' => true ] ] ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => '#REDIRECT [[File:Other file.png]]', + ] ); + } + + public function testTooBigEdit() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The content you supplied exceeds the article size limit of 1 kilobyte.' ); + + $this->setMwGlobals( 'wgMaxArticleSize', 1 ); + + $text = str_repeat( '!', 1025 ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => $text, + ] ); + } + + public function testProhibitedAnonymousEdit() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + 'The action you have requested is limited to users in the group: ' ); + + $this->setMwGlobals( 'wgRevokePermissions', [ '*' => [ 'edit' => true ] ] ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + ], null, new User() ); + } + + public function testProhibitedChangeContentModel() { + $name = 'Help:' . ucfirst( __FUNCTION__ ); + + $this->setExpectedException( ApiUsageException::class, + "You don't have permission to change the content model of a page." ); + + $this->setMwGlobals( 'wgRevokePermissions', + [ 'user' => [ 'editcontentmodel' => true ] ] ); + + $this->doApiRequestWithToken( [ + 'action' => 'edit', + 'title' => $name, + 'text' => 'Some text', + 'contentmodel' => 'json', + ] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php b/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php new file mode 100644 index 00000000..aa579ab0 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php @@ -0,0 +1,642 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group API + */ +class ApiErrorFormatterTest extends MediaWikiLangTestCase { + + /** + * @covers ApiErrorFormatter + */ + public function testErrorFormatterBasics() { + $result = new ApiResult( 8388608 ); + $formatter = new ApiErrorFormatter( $result, Language::factory( 'de' ), 'wikitext', false ); + $this->assertSame( 'de', $formatter->getLanguage()->getCode() ); + + $formatter->addMessagesFromStatus( null, Status::newGood() ); + $this->assertSame( + [ ApiResult::META_TYPE => 'assoc' ], + $result->getResultData() + ); + + $this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) ); + + $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter ); + $this->assertSame( + 'Blah "kbd" <X> 😊', + $wrappedFormatter->stripMarkup( 'Blah <kbd>kbd</kbd> <b><X></b> 😊' ), + 'stripMarkup' + ); + } + + /** + * @covers ApiErrorFormatter + * @dataProvider provideErrorFormatter + */ + public function testErrorFormatter( $format, $lang, $useDB, + $expect1, $expect2, $expect3 + ) { + $result = new ApiResult( 8388608 ); + $formatter = new ApiErrorFormatter( $result, Language::factory( $lang ), $format, $useDB ); + + // Add default type + $expect1[ApiResult::META_TYPE] = 'assoc'; + $expect2[ApiResult::META_TYPE] = 'assoc'; + $expect3[ApiResult::META_TYPE] = 'assoc'; + + $formatter->addWarning( 'string', 'mainpage' ); + $formatter->addError( 'err', 'mainpage' ); + $this->assertEquals( $expect1, $result->getResultData(), 'Simple test' ); + + $result->reset(); + $formatter->addWarning( 'foo', 'mainpage' ); + $formatter->addWarning( 'foo', 'mainpage' ); + $formatter->addWarning( 'foo', [ 'parentheses', 'foobar' ] ); + $msg1 = wfMessage( 'mainpage' ); + $formatter->addWarning( 'message', $msg1 ); + $msg2 = new ApiMessage( 'mainpage', 'overriddenCode', [ 'overriddenData' => true ] ); + $formatter->addWarning( 'messageWithData', $msg2 ); + $formatter->addError( 'errWithData', $msg2 ); + $this->assertSame( $expect2, $result->getResultData(), 'Complex test' ); + + $this->assertEquals( + $this->removeModuleTag( $expect2['warnings'][2] ), + $formatter->formatMessage( $msg1 ), + 'formatMessage test 1' + ); + $this->assertEquals( + $this->removeModuleTag( $expect2['warnings'][3] ), + $formatter->formatMessage( $msg2 ), + 'formatMessage test 2' + ); + + $result->reset(); + $status = Status::newGood(); + $status->warning( 'mainpage' ); + $status->warning( 'parentheses', 'foobar' ); + $status->warning( $msg1 ); + $status->warning( $msg2 ); + $status->error( 'mainpage' ); + $status->error( 'parentheses', 'foobar' ); + $formatter->addMessagesFromStatus( 'status', $status ); + $this->assertSame( $expect3, $result->getResultData(), 'Status test' ); + + $this->assertSame( + array_map( [ $this, 'removeModuleTag' ], $expect3['errors'] ), + $formatter->arrayFromStatus( $status, 'error' ), + 'arrayFromStatus test for error' + ); + $this->assertSame( + array_map( [ $this, 'removeModuleTag' ], $expect3['warnings'] ), + $formatter->arrayFromStatus( $status, 'warning' ), + 'arrayFromStatus test for warning' + ); + } + + private function removeModuleTag( $s ) { + if ( is_array( $s ) ) { + unset( $s['module'] ); + } + return $s; + } + + public static function provideErrorFormatter() { + $mainpageText = wfMessage( 'mainpage' )->inLanguage( 'de' )->useDatabase( false )->text(); + $parensText = wfMessage( 'parentheses', 'foobar' )->inLanguage( 'de' ) + ->useDatabase( false )->text(); + $mainpageHTML = wfMessage( 'mainpage' )->inLanguage( 'en' )->parse(); + $parensHTML = wfMessage( 'parentheses', 'foobar' )->inLanguage( 'en' )->parse(); + $C = ApiResult::META_CONTENT; + $I = ApiResult::META_INDEXED_TAG_NAME; + $overriddenData = [ 'overriddenData' => true, ApiResult::META_TYPE => 'assoc' ]; + + return [ + $tmp = [ 'wikitext', 'de', false, + [ + 'errors' => [ + [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'err', $C => 'text' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'string', $C => 'text' ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ 'code' => 'overriddenCode', 'text' => $mainpageText, + 'data' => $overriddenData, 'module' => 'errWithData', $C => 'text' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'foo', $C => 'text' ], + [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'foo', $C => 'text' ], + [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'message', $C => 'text' ], + [ 'code' => 'overriddenCode', 'text' => $mainpageText, + 'data' => $overriddenData, 'module' => 'messageWithData', $C => 'text' ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'status', $C => 'text' ], + [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'status', $C => 'text' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'status', $C => 'text' ], + [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'status', $C => 'text' ], + [ 'code' => 'overriddenCode', 'text' => $mainpageText, + 'data' => $overriddenData, 'module' => 'status', $C => 'text' ], + $I => 'warning', + ], + ], + ], + [ 'plaintext' ] + $tmp, // For these messages, plaintext and wikitext are the same + [ 'html', 'en', true, + [ + 'errors' => [ + [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'err', $C => 'html' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'string', $C => 'html' ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ 'code' => 'overriddenCode', 'html' => $mainpageHTML, + 'data' => $overriddenData, 'module' => 'errWithData', $C => 'html' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'foo', $C => 'html' ], + [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'foo', $C => 'html' ], + [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'message', $C => 'html' ], + [ 'code' => 'overriddenCode', 'html' => $mainpageHTML, + 'data' => $overriddenData, 'module' => 'messageWithData', $C => 'html' ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'status', $C => 'html' ], + [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'status', $C => 'html' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'status', $C => 'html' ], + [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'status', $C => 'html' ], + [ 'code' => 'overriddenCode', 'html' => $mainpageHTML, + 'data' => $overriddenData, 'module' => 'status', $C => 'html' ], + $I => 'warning', + ], + ], + ], + [ 'raw', 'fr', true, + [ + 'errors' => [ + [ + 'code' => 'mainpage', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'module' => 'err', + ], + $I => 'error', + ], + 'warnings' => [ + [ + 'code' => 'mainpage', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'module' => 'string', + ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ + 'code' => 'overriddenCode', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'data' => $overriddenData, + 'module' => 'errWithData', + ], + $I => 'error', + ], + 'warnings' => [ + [ + 'code' => 'mainpage', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'module' => 'foo', + ], + [ + 'code' => 'parentheses', + 'key' => 'parentheses', + 'params' => [ 'foobar', $I => 'param' ], + 'module' => 'foo', + ], + [ + 'code' => 'mainpage', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'module' => 'message', + ], + [ + 'code' => 'overriddenCode', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'data' => $overriddenData, + 'module' => 'messageWithData', + ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ + 'code' => 'mainpage', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'module' => 'status', + ], + [ + 'code' => 'parentheses', + 'key' => 'parentheses', + 'params' => [ 'foobar', $I => 'param' ], + 'module' => 'status', + ], + $I => 'error', + ], + 'warnings' => [ + [ + 'code' => 'mainpage', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'module' => 'status', + ], + [ + 'code' => 'parentheses', + 'key' => 'parentheses', + 'params' => [ 'foobar', $I => 'param' ], + 'module' => 'status', + ], + [ + 'code' => 'overriddenCode', + 'key' => 'mainpage', + 'params' => [ $I => 'param' ], + 'data' => $overriddenData, + 'module' => 'status', + ], + $I => 'warning', + ], + ], + ], + [ 'none', 'fr', true, + [ + 'errors' => [ + [ 'code' => 'mainpage', 'module' => 'err' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'module' => 'string' ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ 'code' => 'overriddenCode', 'data' => $overriddenData, + 'module' => 'errWithData' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'module' => 'foo' ], + [ 'code' => 'parentheses', 'module' => 'foo' ], + [ 'code' => 'mainpage', 'module' => 'message' ], + [ 'code' => 'overriddenCode', 'data' => $overriddenData, + 'module' => 'messageWithData' ], + $I => 'warning', + ], + ], + [ + 'errors' => [ + [ 'code' => 'mainpage', 'module' => 'status' ], + [ 'code' => 'parentheses', 'module' => 'status' ], + $I => 'error', + ], + 'warnings' => [ + [ 'code' => 'mainpage', 'module' => 'status' ], + [ 'code' => 'parentheses', 'module' => 'status' ], + [ 'code' => 'overriddenCode', 'data' => $overriddenData, 'module' => 'status' ], + $I => 'warning', + ], + ], + ], + ]; + } + + /** + * @covers ApiErrorFormatter_BackCompat + */ + public function testErrorFormatterBC() { + $mainpagePlain = wfMessage( 'mainpage' )->useDatabase( false )->plain(); + $parensPlain = wfMessage( 'parentheses', 'foobar' )->useDatabase( false )->plain(); + + $result = new ApiResult( 8388608 ); + $formatter = new ApiErrorFormatter_BackCompat( $result ); + + $this->assertSame( 'en', $formatter->getLanguage()->getCode() ); + + $this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) ); + + $formatter->addWarning( 'string', 'mainpage' ); + $formatter->addWarning( 'raw', + new RawMessage( 'Blah <kbd>kbd</kbd> <b><X></b> 😞' ) + ); + $formatter->addError( 'err', 'mainpage' ); + $this->assertSame( [ + 'error' => [ + 'code' => 'mainpage', + 'info' => $mainpagePlain, + ], + 'warnings' => [ + 'raw' => [ + 'warnings' => 'Blah "kbd" <X> 😞', + ApiResult::META_CONTENT => 'warnings', + ], + 'string' => [ + 'warnings' => $mainpagePlain, + ApiResult::META_CONTENT => 'warnings', + ], + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData(), 'Simple test' ); + + $result->reset(); + $formatter->addWarning( 'foo', 'mainpage' ); + $formatter->addWarning( 'foo', 'mainpage' ); + $formatter->addWarning( 'xxx+foo', [ 'parentheses', 'foobar' ] ); + $msg1 = wfMessage( 'mainpage' ); + $formatter->addWarning( 'message', $msg1 ); + $msg2 = new ApiMessage( 'mainpage', 'overriddenCode', [ 'overriddenData' => true ] ); + $formatter->addWarning( 'messageWithData', $msg2 ); + $formatter->addError( 'errWithData', $msg2 ); + $formatter->addWarning( null, 'mainpage' ); + $this->assertSame( [ + 'error' => [ + 'code' => 'overriddenCode', + 'info' => $mainpagePlain, + 'overriddenData' => true, + ], + 'warnings' => [ + 'unknown' => [ + 'warnings' => $mainpagePlain, + ApiResult::META_CONTENT => 'warnings', + ], + 'messageWithData' => [ + 'warnings' => $mainpagePlain, + ApiResult::META_CONTENT => 'warnings', + ], + 'message' => [ + 'warnings' => $mainpagePlain, + ApiResult::META_CONTENT => 'warnings', + ], + 'foo' => [ + 'warnings' => "$mainpagePlain\n$parensPlain", + ApiResult::META_CONTENT => 'warnings', + ], + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData(), 'Complex test' ); + + $this->assertSame( + [ + 'code' => 'mainpage', + 'info' => 'Main Page', + ], + $formatter->formatMessage( $msg1 ) + ); + $this->assertSame( + [ + 'code' => 'overriddenCode', + 'info' => 'Main Page', + 'overriddenData' => true, + ], + $formatter->formatMessage( $msg2 ) + ); + + $result->reset(); + $status = Status::newGood(); + $status->warning( 'mainpage' ); + $status->warning( 'parentheses', 'foobar' ); + $status->warning( $msg1 ); + $status->warning( $msg2 ); + $status->error( 'mainpage' ); + $status->error( 'parentheses', 'foobar' ); + $formatter->addMessagesFromStatus( 'status', $status ); + $this->assertSame( [ + 'error' => [ + 'code' => 'mainpage', + 'info' => $mainpagePlain, + ], + 'warnings' => [ + 'status' => [ + 'warnings' => "$mainpagePlain\n$parensPlain", + ApiResult::META_CONTENT => 'warnings', + ], + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData(), 'Status test' ); + + $I = ApiResult::META_INDEXED_TAG_NAME; + $this->assertSame( + [ + [ + 'message' => 'mainpage', + 'params' => [ $I => 'param' ], + 'code' => 'mainpage', + 'type' => 'error', + ], + [ + 'message' => 'parentheses', + 'params' => [ 'foobar', $I => 'param' ], + 'code' => 'parentheses', + 'type' => 'error', + ], + $I => 'error', + ], + $formatter->arrayFromStatus( $status, 'error' ), + 'arrayFromStatus test for error' + ); + $this->assertSame( + [ + [ + 'message' => 'mainpage', + 'params' => [ $I => 'param' ], + 'code' => 'mainpage', + 'type' => 'warning', + ], + [ + 'message' => 'parentheses', + 'params' => [ 'foobar', $I => 'param' ], + 'code' => 'parentheses', + 'type' => 'warning', + ], + [ + 'message' => 'mainpage', + 'params' => [ $I => 'param' ], + 'code' => 'mainpage', + 'type' => 'warning', + ], + [ + 'message' => 'mainpage', + 'params' => [ $I => 'param' ], + 'code' => 'overriddenCode', + 'type' => 'warning', + ], + $I => 'warning', + ], + $formatter->arrayFromStatus( $status, 'warning' ), + 'arrayFromStatus test for warning' + ); + + $result->reset(); + $result->addValue( null, 'error', [ 'bogus' ] ); + $formatter->addError( 'err', 'mainpage' ); + $this->assertSame( [ + 'error' => [ + 'code' => 'mainpage', + 'info' => $mainpagePlain, + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData(), 'Overwrites bogus "error" value with real error' ); + } + + /** + * @dataProvider provideGetMessageFromException + * @covers ApiErrorFormatter::getMessageFromException + * @covers ApiErrorFormatter::formatException + * @param Exception $exception + * @param array $options + * @param array $expect + */ + public function testGetMessageFromException( $exception, $options, $expect ) { + if ( $exception instanceof UsageException ) { + $this->hideDeprecated( 'UsageException::getMessageArray' ); + } + + $result = new ApiResult( 8388608 ); + $formatter = new ApiErrorFormatter( $result, Language::factory( 'en' ), 'html', false ); + + $msg = $formatter->getMessageFromException( $exception, $options ); + $this->assertInstanceOf( Message::class, $msg ); + $this->assertInstanceOf( IApiMessage::class, $msg ); + $this->assertSame( $expect, [ + 'text' => $msg->parse(), + 'code' => $msg->getApiCode(), + 'data' => $msg->getApiData(), + ] ); + + $expectFormatted = $formatter->formatMessage( $msg ); + $formatted = $formatter->formatException( $exception, $options ); + $this->assertSame( $expectFormatted, $formatted ); + } + + /** + * @dataProvider provideGetMessageFromException + * @covers ApiErrorFormatter_BackCompat::formatException + * @param Exception $exception + * @param array $options + * @param array $expect + */ + public function testGetMessageFromException_BC( $exception, $options, $expect ) { + $result = new ApiResult( 8388608 ); + $formatter = new ApiErrorFormatter_BackCompat( $result ); + + $msg = $formatter->getMessageFromException( $exception, $options ); + $this->assertInstanceOf( Message::class, $msg ); + $this->assertInstanceOf( IApiMessage::class, $msg ); + $this->assertSame( $expect, [ + 'text' => $msg->parse(), + 'code' => $msg->getApiCode(), + 'data' => $msg->getApiData(), + ] ); + + $expectFormatted = $formatter->formatMessage( $msg ); + $formatted = $formatter->formatException( $exception, $options ); + $this->assertSame( $expectFormatted, $formatted ); + $formatted = $formatter->formatException( $exception, $options + [ 'bc' => true ] ); + $this->assertSame( $expectFormatted['info'], $formatted ); + } + + public static function provideGetMessageFromException() { + Wikimedia\suppressWarnings(); + $usageException = new UsageException( + '<b>Something broke!</b>', 'ue-code', 0, [ 'xxx' => 'yyy', 'baz' => 23 ] + ); + Wikimedia\restoreWarnings(); + + return [ + 'Normal exception' => [ + new RuntimeException( '<b>Something broke!</b>' ), + [], + [ + 'text' => '<b>Something broke!</b>', + 'code' => 'internal_api_error_RuntimeException', + 'data' => [], + ] + ], + 'Normal exception, wrapped' => [ + new RuntimeException( '<b>Something broke!</b>' ), + [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ], + [ + 'text' => '(<b>Something broke!</b>)', + 'code' => 'some-code', + 'data' => [ 'foo' => 'bar', 'baz' => 42 ], + ] + ], + 'UsageException' => [ + $usageException, + [], + [ + 'text' => '<b>Something broke!</b>', + 'code' => 'ue-code', + 'data' => [ 'xxx' => 'yyy', 'baz' => 23 ], + ] + ], + 'UsageException, wrapped' => [ + $usageException, + [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ], + [ + 'text' => '(<b>Something broke!</b>)', + 'code' => 'some-code', + 'data' => [ 'xxx' => 'yyy', 'baz' => 42, 'foo' => 'bar' ], + ] + ], + 'LocalizedException' => [ + new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ), + [], + [ + 'text' => 'Return to <b>FooBar</b>.', + 'code' => 'returnto', + 'data' => [], + ] + ], + 'LocalizedException, wrapped' => [ + new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ), + [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ], + [ + 'text' => 'Return to <b>FooBar</b>.', + 'code' => 'some-code', + 'data' => [ 'foo' => 'bar', 'baz' => 42 ], + ] + ], + ]; + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php b/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php new file mode 100644 index 00000000..d382c83c --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php @@ -0,0 +1,301 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiLogin + */ +class ApiLoginTest extends ApiTestCase { + + /** + * Test result of attempted login with an empty username + */ + public function testApiLoginNoName() { + $session = [ + 'wsTokenSecrets' => [ 'login' => 'foobar' ], + ]; + $data = $this->doApiRequest( [ 'action' => 'login', + 'lgname' => '', 'lgpassword' => self::$users['sysop']->getPassword(), + 'lgtoken' => (string)( new MediaWiki\Session\Token( 'foobar', '' ) ) + ], $session ); + $this->assertEquals( 'Failed', $data[0]['login']['result'] ); + } + + public function testApiLoginBadPass() { + global $wgServer; + + $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); + $user->getUser()->logout(); + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + $ret = $this->doApiRequest( [ + "action" => "login", + "lgname" => $userName, + "lgpassword" => "bad", + ] ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + $this->assertEquals( "NeedToken", $a ); + + $token = $result["login"]["token"]; + + $ret = $this->doApiRequest( + [ + "action" => "login", + "lgtoken" => $token, + "lgname" => $userName, + "lgpassword" => "badnowayinhell", + ], + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + + $this->assertEquals( 'Failed', $a ); + } + + public function testApiLoginGoodPass() { + global $wgServer; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + + $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); + $user->getUser()->logout(); + + $ret = $this->doApiRequest( [ + "action" => "login", + "lgname" => $userName, + "lgpassword" => $password, + ] + ); + + $result = $ret[0]; + $this->assertNotInternalType( "bool", $result ); + $this->assertNotInternalType( "null", $result["login"] ); + + $a = $result["login"]["result"]; + $this->assertEquals( "NeedToken", $a ); + $token = $result["login"]["token"]; + + $ret = $this->doApiRequest( + [ + "action" => "login", + "lgtoken" => $token, + "lgname" => $userName, + "lgpassword" => $password, + ], + $ret[2] + ); + + $result = $ret[0]; + + $this->assertNotInternalType( "bool", $result ); + $a = $result["login"]["result"]; + + $this->assertEquals( "Success", $a ); + } + + /** + * @group Broken + */ + public function testApiLoginGotCookie() { + $this->markTestIncomplete( "The server can't do external HTTP requests, " + . "and the internal one won't give cookies" ); + + global $wgServer, $wgScriptPath; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); + + $req = MWHttpRequest::factory( self::$apiUrl . "?action=login&format=xml", + [ "method" => "POST", + "postData" => [ + "lgname" => $userName, + "lgpassword" => $password + ] + ], + __METHOD__ + ); + $req->execute(); + + libxml_use_internal_errors( true ); + $sxe = simplexml_load_string( $req->getContent() ); + $this->assertNotInternalType( "bool", $sxe ); + $this->assertThat( $sxe, $this->isInstanceOf( SimpleXMLElement::class ) ); + $this->assertNotInternalType( "null", $sxe->login[0] ); + + $a = $sxe->login[0]->attributes()->result[0]; + $this->assertEquals( ' result="NeedToken"', $a->asXML() ); + $token = (string)$sxe->login[0]->attributes()->token; + + $req->setData( [ + "lgtoken" => $token, + "lgname" => $userName, + "lgpassword" => $password ] ); + $req->execute(); + + $cj = $req->getCookieJar(); + $serverName = parse_url( $wgServer, PHP_URL_HOST ); + $this->assertNotEquals( false, $serverName ); + $serializedCookie = $cj->serializeToHttpRequest( $wgScriptPath, $serverName ); + $this->assertNotEquals( '', $serializedCookie ); + $this->assertRegExp( + '/_session=[^;]*; .*UserID=[0-9]*; .*UserName=' . $user->userName . '; .*Token=/', + $serializedCookie + ); + } + + public function testRunLogin() { + $user = self::$users['sysop']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); + + $data = $this->doApiRequest( [ + 'action' => 'login', + 'lgname' => $userName, + 'lgpassword' => $password ] ); + + $this->assertArrayHasKey( "login", $data[0] ); + $this->assertArrayHasKey( "result", $data[0]['login'] ); + $this->assertEquals( "NeedToken", $data[0]['login']['result'] ); + $token = $data[0]['login']['token']; + + $data = $this->doApiRequest( [ + 'action' => 'login', + "lgtoken" => $token, + "lgname" => $userName, + "lgpassword" => $password ], $data[2] ); + + $this->assertArrayHasKey( "login", $data[0] ); + $this->assertArrayHasKey( "result", $data[0]['login'] ); + $this->assertEquals( "Success", $data[0]['login']['result'] ); + } + + public function testBotPassword() { + global $wgServer, $wgSessionProviders; + + if ( !isset( $wgServer ) ) { + $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' ); + } + + $this->setMwGlobals( [ + 'wgSessionProviders' => array_merge( $wgSessionProviders, [ + [ + 'class' => MediaWiki\Session\BotPasswordSessionProvider::class, + 'args' => [ [ 'priority' => 40 ] ], + ] + ] ), + 'wgEnableBotPasswords' => true, + 'wgBotPasswordsDatabase' => false, + 'wgCentralIdLookupProvider' => 'local', + 'wgGrantPermissions' => [ + 'test' => [ 'read' => true ], + ], + ] ); + + // Make sure our session provider is present + $manager = TestingAccessWrapper::newFromObject( MediaWiki\Session\SessionManager::singleton() ); + if ( !isset( $manager->sessionProviders[MediaWiki\Session\BotPasswordSessionProvider::class] ) ) { + $tmp = $manager->sessionProviders; + $manager->sessionProviders = null; + $manager->sessionProviders = $tmp + $manager->getProviders(); + } + $this->assertNotNull( + MediaWiki\Session\SessionManager::singleton()->getProvider( + MediaWiki\Session\BotPasswordSessionProvider::class + ), + 'sanity check' + ); + + $user = self::$users['sysop']; + $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() ); + $this->assertNotEquals( 0, $centralId, 'sanity check' ); + + $password = 'ngfhmjm64hv0854493hsj5nncjud2clk'; + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only + $passwordHash = $passwordFactory->newFromPlaintext( $password ); + + $dbw = wfGetDB( DB_MASTER ); + $dbw->insert( + 'bot_passwords', + [ + 'bp_user' => $centralId, + 'bp_app_id' => 'foo', + 'bp_password' => $passwordHash->toString(), + 'bp_token' => '', + 'bp_restrictions' => MWRestrictions::newDefault()->toJson(), + 'bp_grants' => '["test"]', + ], + __METHOD__ + ); + + $lgName = $user->getUser()->getName() . BotPassword::getSeparator() . 'foo'; + + $ret = $this->doApiRequest( [ + 'action' => 'login', + 'lgname' => $lgName, + 'lgpassword' => $password, + ] ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $this->assertNotInternalType( 'null', $result['login'] ); + + $a = $result['login']['result']; + $this->assertEquals( 'NeedToken', $a ); + $token = $result['login']['token']; + + $ret = $this->doApiRequest( [ + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => $lgName, + 'lgpassword' => $password, + ], $ret[2] ); + + $result = $ret[0]; + $this->assertNotInternalType( 'bool', $result ); + $a = $result['login']['result']; + + $this->assertEquals( 'Success', $a ); + } + + public function testLoginWithNoSameOriginSecurity() { + $this->setTemporaryHook( 'RequestHasSameOriginSecurity', + function () { + return false; + } + ); + + $result = $this->doApiRequest( [ + 'action' => 'login', + ] )[0]['login']; + + $this->assertSame( [ + 'result' => 'Aborted', + 'reason' => 'Cannot log in when the same-origin policy is not applied.', + ], $result ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php b/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php new file mode 100644 index 00000000..8254fdba --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php @@ -0,0 +1,75 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiLogout + */ +class ApiLogoutTest extends ApiTestCase { + + protected function setUp() { + global $wgRequest, $wgUser; + + parent::setUp(); + + // Link the user to the Session properly so User::doLogout() doesn't complain. + $wgRequest->getSession()->setUser( $wgUser ); + $wgUser = User::newFromSession( $wgRequest ); + $this->apiContext->setUser( $wgUser ); + } + + public function testUserLogoutBadToken() { + global $wgUser; + + $this->setExpectedApiException( 'apierror-badtoken' ); + + try { + $token = 'invalid token'; + $this->doUserLogout( $token ); + } finally { + $this->assertTrue( $wgUser->isLoggedIn(), 'not logged out' ); + } + } + + public function testUserLogout() { + global $wgUser; + + $this->assertTrue( $wgUser->isLoggedIn(), 'sanity check' ); + $token = $this->getUserCsrfTokenFromApi(); + $this->doUserLogout( $token ); + $this->assertFalse( $wgUser->isLoggedIn() ); + } + + public function testUserLogoutWithWebToken() { + global $wgUser, $wgRequest; + + $this->assertTrue( $wgUser->isLoggedIn(), 'sanity check' ); + + // Logic copied from SkinTemplate. + $token = $wgUser->getEditToken( 'logoutToken', $wgRequest ); + + $this->doUserLogout( $token ); + $this->assertFalse( $wgUser->isLoggedIn() ); + } + + private function getUserCsrfTokenFromApi() { + $retToken = $this->doApiRequest( [ + 'action' => 'query', + 'meta' => 'tokens', + 'type' => 'csrf' + ] ); + + $this->assertArrayNotHasKey( 'warnings', $retToken ); + + return $retToken[0]['query']['tokens']['csrftoken']; + } + + private function doUserLogout( $logoutToken ) { + return $this->doApiRequest( [ + 'action' => 'logout', + 'token' => $logoutToken + ] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiMainTest.php b/www/wiki/tests/phpunit/includes/api/ApiMainTest.php new file mode 100644 index 00000000..d17334bb --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiMainTest.php @@ -0,0 +1,1072 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiMain + */ +class ApiMainTest extends ApiTestCase { + + /** + * Test that the API will accept a FauxRequest and execute. + */ + public function testApi() { + $api = new ApiMain( + new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) + ); + $api->execute(); + $data = $api->getResult()->getResultData(); + $this->assertInternalType( 'array', $data ); + $this->assertArrayHasKey( 'query', $data ); + } + + public function testApiNoParam() { + $api = new ApiMain(); + $api->execute(); + $data = $api->getResult()->getResultData(); + $this->assertInternalType( 'array', $data ); + } + + /** + * ApiMain behaves differently if passed a FauxRequest (mInternalMode set + * to true) or a proper WebRequest (mInternalMode false). For most tests + * we can just set mInternalMode to false using TestingAccessWrapper, but + * this doesn't work for the constructor. This method returns an ApiMain + * that's been set up in non-internal mode. + * + * Note that calling execute() will print to the console. Wrap it in + * ob_start()/ob_end_clean() to prevent this. + * + * @param array $requestData Query parameters for the WebRequest + * @param array $headers Headers for the WebRequest + */ + private function getNonInternalApiMain( array $requestData, array $headers = [] ) { + $req = $this->getMockBuilder( WebRequest::class ) + ->setMethods( [ 'response', 'getRawIP' ] ) + ->getMock(); + $response = new FauxResponse(); + $req->method( 'response' )->willReturn( $response ); + $req->method( 'getRawIP' )->willReturn( '127.0.0.1' ); + + $wrapper = TestingAccessWrapper::newFromObject( $req ); + $wrapper->data = $requestData; + if ( $headers ) { + $wrapper->headers = $headers; + } + + return new ApiMain( $req ); + } + + public function testUselang() { + global $wgLang; + + $api = $this->getNonInternalApiMain( [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'uselang' => 'fr', + ] ); + + ob_start(); + $api->execute(); + ob_end_clean(); + + $this->assertSame( 'fr', $wgLang->getCode() ); + } + + public function testNonWhitelistedCorsWithCookies() { + $logFile = $this->getNewTempFile(); + + $this->mergeMwGlobalArrayValue( '_COOKIE', [ 'forceHTTPS' => '1' ] ); + $logger = new TestLogger( true ); + $this->setLogger( 'cors', $logger ); + + $api = $this->getNonInternalApiMain( [ + 'action' => 'query', + 'meta' => 'siteinfo', + // For some reason multiple origins (which are not allowed in the + // WHATWG Fetch spec that supersedes the RFC) are always considered to + // be problematic. + ], [ 'ORIGIN' => 'https://www.example.com https://www.com.example' ] ); + + $this->assertSame( + [ [ Psr\Log\LogLevel::WARNING, 'Non-whitelisted CORS request with session cookies' ] ], + $logger->getBuffer() + ); + } + + public function testSuppressedLogin() { + global $wgUser; + $origUser = $wgUser; + + $api = $this->getNonInternalApiMain( [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'origin' => '*', + ] ); + + ob_start(); + $api->execute(); + ob_end_clean(); + + $this->assertNotSame( $origUser, $wgUser ); + $this->assertSame( 'true', $api->getContext()->getRequest()->response() + ->getHeader( 'MediaWiki-Login-Suppressed' ) ); + } + + public function testSetContinuationManager() { + $api = new ApiMain(); + $manager = $this->createMock( ApiContinuationManager::class ); + $api->setContinuationManager( $manager ); + $this->assertTrue( true, 'No exception' ); + return [ $api, $manager ]; + } + + /** + * @depends testSetContinuationManager + */ + public function testSetContinuationManagerTwice( $args ) { + $this->setExpectedException( UnexpectedValueException::class, + 'ApiMain::setContinuationManager: tried to set manager from ' . + 'when a manager is already set from ' ); + + list( $api, $manager ) = $args; + $api->setContinuationManager( $manager ); + } + + public function testSetCacheModeUnrecognized() { + $api = new ApiMain(); + $api->setCacheMode( 'unrecognized' ); + $this->assertSame( + 'private', + TestingAccessWrapper::newFromObject( $api )->mCacheMode, + 'Unrecognized params must be silently ignored' + ); + } + + public function testSetCacheModePrivateWiki() { + $this->setGroupPermissions( '*', 'read', false ); + + $wrappedApi = TestingAccessWrapper::newFromObject( new ApiMain() ); + $wrappedApi->setCacheMode( 'public' ); + $this->assertSame( 'private', $wrappedApi->mCacheMode ); + $wrappedApi->setCacheMode( 'anon-public-user-private' ); + $this->assertSame( 'private', $wrappedApi->mCacheMode ); + } + + public function testAddRequestedFieldsRequestId() { + $req = new FauxRequest( [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'requestid' => '123456', + ] ); + $api = new ApiMain( $req ); + $api->execute(); + $this->assertSame( '123456', $api->getResult()->getResultData()['requestid'] ); + } + + public function testAddRequestedFieldsCurTimestamp() { + $req = new FauxRequest( [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'curtimestamp' => '', + ] ); + $api = new ApiMain( $req ); + $api->execute(); + $timestamp = $api->getResult()->getResultData()['curtimestamp']; + $this->assertLessThanOrEqual( 1, abs( strtotime( $timestamp ) - time() ) ); + } + + public function testAddRequestedFieldsResponseLangInfo() { + $req = new FauxRequest( [ + 'action' => 'query', + 'meta' => 'siteinfo', + // errorlang is ignored if errorformat is not specified + 'errorformat' => 'plaintext', + 'uselang' => 'FR', + 'errorlang' => 'ja', + 'responselanginfo' => '', + ] ); + $api = new ApiMain( $req ); + $api->execute(); + $data = $api->getResult()->getResultData(); + $this->assertSame( 'fr', $data['uselang'] ); + $this->assertSame( 'ja', $data['errorlang'] ); + } + + public function testSetupModuleUnknown() { + $this->setExpectedException( ApiUsageException::class, + 'Unrecognized value for parameter "action": unknownaction.' ); + + $req = new FauxRequest( [ 'action' => 'unknownaction' ] ); + $api = new ApiMain( $req ); + $api->execute(); + } + + public function testSetupModuleNoTokenProvided() { + $this->setExpectedException( ApiUsageException::class, + 'The "token" parameter must be set.' ); + + $req = new FauxRequest( [ + 'action' => 'edit', + 'title' => 'New page', + 'text' => 'Some text', + ] ); + $api = new ApiMain( $req ); + $api->execute(); + } + + public function testSetupModuleInvalidTokenProvided() { + $this->setExpectedException( ApiUsageException::class, 'Invalid CSRF token.' ); + + $req = new FauxRequest( [ + 'action' => 'edit', + 'title' => 'New page', + 'text' => 'Some text', + 'token' => "This isn't a real token!", + ] ); + $api = new ApiMain( $req ); + $api->execute(); + } + + public function testSetupModuleNeedsTokenTrue() { + $this->setExpectedException( MWException::class, + "Module 'testmodule' must be updated for the new token handling. " . + "See documentation for ApiBase::needsToken for details." ); + + $mock = $this->createMock( ApiBase::class ); + $mock->method( 'getModuleName' )->willReturn( 'testmodule' ); + $mock->method( 'needsToken' )->willReturn( true ); + + $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) ); + $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ), + function () use ( $mock ) { + return $mock; + } + ); + $api->execute(); + } + + public function testSetupModuleNeedsTokenNeedntBePosted() { + $this->setExpectedException( MWException::class, + "Module 'testmodule' must require POST to use tokens." ); + + $mock = $this->createMock( ApiBase::class ); + $mock->method( 'getModuleName' )->willReturn( 'testmodule' ); + $mock->method( 'needsToken' )->willReturn( 'csrf' ); + $mock->method( 'mustBePosted' )->willReturn( false ); + + $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) ); + $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ), + function () use ( $mock ) { + return $mock; + } + ); + $api->execute(); + } + + public function testCheckMaxLagFailed() { + // It's hard to mock the LoadBalancer properly, so instead we'll mock + // checkMaxLag (which is tested directly in other tests below). + $req = new FauxRequest( [ + 'action' => 'query', + 'meta' => 'siteinfo', + ] ); + + $mock = $this->getMockBuilder( ApiMain::class ) + ->setConstructorArgs( [ $req ] ) + ->setMethods( [ 'checkMaxLag' ] ) + ->getMock(); + $mock->method( 'checkMaxLag' )->willReturn( false ); + + $mock->execute(); + + $this->assertArrayNotHasKey( 'query', $mock->getResult()->getResultData() ); + } + + public function testCheckConditionalRequestHeadersFailed() { + // The detailed checking of all cases of checkConditionalRequestHeaders + // is below in testCheckConditionalRequestHeaders(), which calls the + // method directly. Here we just check that it will stop execution if + // it does fail. + $now = time(); + + $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' ); + + $mock = $this->createMock( ApiBase::class ); + $mock->method( 'getModuleName' )->willReturn( 'testmodule' ); + $mock->method( 'getConditionalRequestData' ) + ->willReturn( wfTimestamp( TS_MW, $now - 3600 ) ); + $mock->expects( $this->exactly( 0 ) )->method( 'execute' ); + + $req = new FauxRequest( [ + 'action' => 'testmodule', + ] ); + $req->setHeader( 'If-Modified-Since', wfTimestamp( TS_RFC2822, $now - 3600 ) ); + $req->setRequestURL( "http://localhost" ); + + $api = new ApiMain( $req ); + $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ), + function () use ( $mock ) { + return $mock; + } + ); + + $wrapper = TestingAccessWrapper::newFromObject( $api ); + $wrapper->mInternalMode = false; + + ob_start(); + $api->execute(); + ob_end_clean(); + } + + private function doTestCheckMaxLag( $lag ) { + $mockLB = $this->getMockBuilder( LoadBalancer::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'getMaxLag', '__destruct' ] ) + ->getMock(); + $mockLB->method( 'getMaxLag' )->willReturn( [ 'somehost', $lag ] ); + $this->setService( 'DBLoadBalancer', $mockLB ); + + $req = new FauxRequest(); + + $api = new ApiMain( $req ); + $wrapper = TestingAccessWrapper::newFromObject( $api ); + + $mockModule = $this->createMock( ApiBase::class ); + $mockModule->method( 'shouldCheckMaxLag' )->willReturn( true ); + + try { + $wrapper->checkMaxLag( $mockModule, [ 'maxlag' => 3 ] ); + } finally { + if ( $lag > 3 ) { + $this->assertSame( '5', $req->response()->getHeader( 'Retry-After' ) ); + $this->assertSame( (string)$lag, $req->response()->getHeader( 'X-Database-Lag' ) ); + } + } + } + + public function testCheckMaxLagOkay() { + $this->doTestCheckMaxLag( 3 ); + + // No exception, we're happy + $this->assertTrue( true ); + } + + public function testCheckMaxLagExceeded() { + $this->setExpectedException( ApiUsageException::class, + 'Waiting for a database server: 4 seconds lagged.' ); + + $this->setMwGlobals( 'wgShowHostnames', false ); + + $this->doTestCheckMaxLag( 4 ); + } + + public function testCheckMaxLagExceededWithHostNames() { + $this->setExpectedException( ApiUsageException::class, + 'Waiting for somehost: 4 seconds lagged.' ); + + $this->setMwGlobals( 'wgShowHostnames', true ); + + $this->doTestCheckMaxLag( 4 ); + } + + public static function provideAssert() { + return [ + [ false, [], 'user', 'assertuserfailed' ], + [ true, [], 'user', false ], + [ true, [], 'bot', 'assertbotfailed' ], + [ true, [ 'bot' ], 'user', false ], + [ true, [ 'bot' ], 'bot', false ], + ]; + } + + /** + * Tests the assert={user|bot} functionality + * + * @dataProvider provideAssert + * @param bool $registered + * @param array $rights + * @param string $assert + * @param string|bool $error False if no error expected + */ + public function testAssert( $registered, $rights, $assert, $error ) { + if ( $registered ) { + $user = $this->getMutableTestUser()->getUser(); + $user->load(); // load before setting mRights + } else { + $user = new User(); + } + $user->mRights = $rights; + try { + $this->doApiRequest( [ + 'action' => 'query', + 'assert' => $assert, + ], null, null, $user ); + $this->assertFalse( $error ); // That no error was expected + } catch ( ApiUsageException $e ) { + $this->assertTrue( self::apiExceptionHasCode( $e, $error ), + "Error '{$e->getMessage()}' matched expected '$error'" ); + } + } + + /** + * Tests the assertuser= functionality + */ + public function testAssertUser() { + $user = $this->getTestUser()->getUser(); + $this->doApiRequest( [ + 'action' => 'query', + 'assertuser' => $user->getName(), + ], null, null, $user ); + + try { + $this->doApiRequest( [ + 'action' => 'query', + 'assertuser' => $user->getName() . 'X', + ], null, null, $user ); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $e ) { + $this->assertTrue( self::apiExceptionHasCode( $e, 'assertnameduserfailed' ) ); + } + } + + /** + * Test if all classes in the main module manager exists + */ + public function testClassNamesInModuleManager() { + $api = new ApiMain( + new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) + ); + $modules = $api->getModuleManager()->getNamesWithClasses(); + + foreach ( $modules as $name => $class ) { + $this->assertTrue( + class_exists( $class ), + 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)' + ); + } + } + + /** + * Test HTTP precondition headers + * + * @dataProvider provideCheckConditionalRequestHeaders + * @param array $headers HTTP headers + * @param array $conditions Return data for ApiBase::getConditionalRequestData + * @param int $status Expected response status + * @param array $options Array of options: + * post => true Request is a POST + * cdn => true CDN is enabled ($wgUseSquid) + */ + public function testCheckConditionalRequestHeaders( + $headers, $conditions, $status, $options = [] + ) { + $request = new FauxRequest( + [ 'action' => 'query', 'meta' => 'siteinfo' ], + !empty( $options['post'] ) + ); + $request->setHeaders( $headers ); + $request->response()->statusHeader( 200 ); // Why doesn't it default? + + $context = $this->apiContext->newTestContext( $request, null ); + $api = new ApiMain( $context ); + $priv = TestingAccessWrapper::newFromObject( $api ); + $priv->mInternalMode = false; + + if ( !empty( $options['cdn'] ) ) { + $this->setMwGlobals( 'wgUseSquid', true ); + } + + // Can't do this in TestSetup.php because Setup.php will override it + $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' ); + + $module = $this->getMockBuilder( ApiBase::class ) + ->setConstructorArgs( [ $api, 'mock' ] ) + ->setMethods( [ 'getConditionalRequestData' ] ) + ->getMockForAbstractClass(); + $module->expects( $this->any() ) + ->method( 'getConditionalRequestData' ) + ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) { + return isset( $conditions[$condition] ) ? $conditions[$condition] : null; + } ) ); + + $ret = $priv->checkConditionalRequestHeaders( $module ); + + $this->assertSame( $status, $request->response()->getStatusCode() ); + $this->assertSame( $status === 200, $ret ); + } + + public static function provideCheckConditionalRequestHeaders() { + global $wgSquidMaxage; + $now = time(); + + return [ + // Non-existing from module is ignored + 'If-None-Match' => [ [ 'If-None-Match' => '"foo", "bar"' ], [], 200 ], + 'If-Modified-Since' => + [ [ 'If-Modified-Since' => 'Tue, 18 Aug 2015 00:00:00 GMT' ], [], 200 ], + + // No headers + 'No headers' => [ [], [ 'etag' => '""', 'last-modified' => '20150815000000', ], 200 ], + + // Basic If-None-Match + 'If-None-Match with matching etag' => + [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 304 ], + 'If-None-Match with non-matching etag' => + [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"baz"' ], 200 ], + 'Strong If-None-Match with weak matching etag' => + [ [ 'If-None-Match' => '"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ], + 'Weak If-None-Match with strong matching etag' => + [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => '"foo"' ], 304 ], + 'Weak If-None-Match with weak matching etag' => + [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ], + + // Pointless for GET, but supported + 'If-None-Match: *' => [ [ 'If-None-Match' => '*' ], [], 304 ], + + // Basic If-Modified-Since + 'If-Modified-Since, modified one second earlier' => + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ], + 'If-Modified-Since, modified now' => + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now ) ], 304 ], + 'If-Modified-Since, modified one second later' => + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now + 1 ) ], 200 ], + + // If-Modified-Since ignored when If-None-Match is given too + 'Non-matching If-None-Match and matching If-Modified-Since' => + [ [ 'If-None-Match' => '""', + 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ], + [ 'etag' => '"x"', 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ], + 'Non-matching If-None-Match and matching If-Modified-Since with no ETag' => + [ + [ + 'If-None-Match' => '""', + 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) + ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], + 304 + ], + + // Ignored for POST + 'Matching If-None-Match with POST' => + [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 200, + [ 'post' => true ] ], + 'Matching If-Modified-Since with POST' => + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200, + [ 'post' => true ] ], + + // Other date formats allowed by the RFC + 'If-Modified-Since with alternate date format 1' => + [ [ 'If-Modified-Since' => gmdate( 'l, d-M-y H:i:s', $now ) . ' GMT' ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ], + 'If-Modified-Since with alternate date format 2' => + [ [ 'If-Modified-Since' => gmdate( 'D M j H:i:s Y', $now ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ], + + // Old browser extension to HTTP/1.0 + 'If-Modified-Since with length' => + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) . '; length=123' ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ], + + // Invalid date formats should be ignored + 'If-Modified-Since with invalid date format' => + [ [ 'If-Modified-Since' => gmdate( 'Y-m-d H:i:s', $now ) . ' GMT' ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ], + 'If-Modified-Since with entirely unparseable date' => + [ [ 'If-Modified-Since' => 'a potato' ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ], + + // Anything before $wgSquidMaxage seconds ago should be considered + // expired. + 'If-Modified-Since with CDN post-expiry' => + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage * 2 ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ], + 200, [ 'cdn' => true ] ], + 'If-Modified-Since with CDN pre-expiry' => + [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage / 2 ) ], + [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ], + 304, [ 'cdn' => true ] ], + ]; + } + + /** + * Test conditional headers output + * @dataProvider provideConditionalRequestHeadersOutput + * @param array $conditions Return data for ApiBase::getConditionalRequestData + * @param array $headers Expected output headers + * @param bool $isError $isError flag + * @param bool $post Request is a POST + */ + public function testConditionalRequestHeadersOutput( + $conditions, $headers, $isError = false, $post = false + ) { + $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post ); + $response = $request->response(); + + $api = new ApiMain( $request ); + $priv = TestingAccessWrapper::newFromObject( $api ); + $priv->mInternalMode = false; + + $module = $this->getMockBuilder( ApiBase::class ) + ->setConstructorArgs( [ $api, 'mock' ] ) + ->setMethods( [ 'getConditionalRequestData' ] ) + ->getMockForAbstractClass(); + $module->expects( $this->any() ) + ->method( 'getConditionalRequestData' ) + ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) { + return isset( $conditions[$condition] ) ? $conditions[$condition] : null; + } ) ); + $priv->mModule = $module; + + $priv->sendCacheHeaders( $isError ); + + foreach ( [ 'Last-Modified', 'ETag' ] as $header ) { + $this->assertEquals( + isset( $headers[$header] ) ? $headers[$header] : null, + $response->getHeader( $header ), + $header + ); + } + } + + public static function provideConditionalRequestHeadersOutput() { + return [ + [ + [], + [] + ], + [ + [ 'etag' => '"foo"' ], + [ 'ETag' => '"foo"' ] + ], + [ + [ 'last-modified' => '20150818000102' ], + [ 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ] + ], + [ + [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ], + [ 'ETag' => '"foo"', 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ] + ], + [ + [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ], + [], + true, + ], + [ + [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ], + [], + false, + true, + ], + ]; + } + + public function testCheckExecutePermissionsReadProhibited() { + $this->setExpectedException( ApiUsageException::class, + 'You need read permission to use this module.' ); + + $this->setGroupPermissions( '*', 'read', false ); + + $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) ); + $main->execute(); + } + + public function testCheckExecutePermissionWriteDisabled() { + $this->setExpectedException( ApiUsageException::class, + 'Editing of this wiki through the API is disabled. Make sure the ' . + '"$wgEnableWriteAPI=true;" statement is included in the wiki\'s ' . + '"LocalSettings.php" file.' ); + $main = new ApiMain( new FauxRequest( [ + 'action' => 'edit', + 'title' => 'Some page', + 'text' => 'Some text', + 'token' => '+\\', + ] ) ); + $main->execute(); + } + + public function testCheckExecutePermissionWriteApiProhibited() { + $this->setExpectedException( ApiUsageException::class, + "You're not allowed to edit this wiki through the API." ); + $this->setGroupPermissions( '*', 'writeapi', false ); + + $main = new ApiMain( new FauxRequest( [ + 'action' => 'edit', + 'title' => 'Some page', + 'text' => 'Some text', + 'token' => '+\\', + ] ), /* enableWrite = */ true ); + $main->execute(); + } + + public function testCheckExecutePermissionPromiseNonWrite() { + $this->setExpectedException( ApiUsageException::class, + 'The "Promise-Non-Write-API-Action" HTTP header cannot be sent ' . + 'to write-mode API modules.' ); + + $req = new FauxRequest( [ + 'action' => 'edit', + 'title' => 'Some page', + 'text' => 'Some text', + 'token' => '+\\', + ] ); + $req->setHeaders( [ 'Promise-Non-Write-API-Action' => '1' ] ); + $main = new ApiMain( $req, /* enableWrite = */ true ); + $main->execute(); + } + + public function testCheckExecutePermissionHookAbort() { + $this->setExpectedException( ApiUsageException::class, 'Main Page' ); + + $this->setTemporaryHook( 'ApiCheckCanExecute', function ( $unused1, $unused2, &$message ) { + $message = 'mainpage'; + return false; + } ); + + $main = new ApiMain( new FauxRequest( [ + 'action' => 'edit', + 'title' => 'Some page', + 'text' => 'Some text', + 'token' => '+\\', + ] ), /* enableWrite = */ true ); + $main->execute(); + } + + public function testGetValUnsupportedArray() { + $main = new ApiMain( new FauxRequest( [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'siprop' => [ 'general', 'namespaces' ], + ] ) ); + $this->assertSame( 'myDefault', $main->getVal( 'siprop', 'myDefault' ) ); + $main->execute(); + $this->assertSame( 'Parameter "siprop" uses unsupported PHP array syntax.', + $main->getResult()->getResultData()['warnings']['main']['warnings'] ); + } + + public function testReportUnusedParams() { + $main = new ApiMain( new FauxRequest( [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'unusedparam' => 'unusedval', + 'anotherunusedparam' => 'anotherval', + ] ) ); + $main->execute(); + $this->assertSame( 'Unrecognized parameters: unusedparam, anotherunusedparam.', + $main->getResult()->getResultData()['warnings']['main']['warnings'] ); + } + + public function testLacksSameOriginSecurity() { + // Basic test + $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) ); + $this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' ); + + // JSONp + $main = new ApiMain( + new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] ) + ); + $this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' ); + + // Header + $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ); + $request->setHeader( 'TrEaT-As-UnTrUsTeD', '' ); // With falsey value! + $main = new ApiMain( $request ); + $this->assertTrue( $main->lacksSameOriginSecurity(), 'Header supplied, should lack security' ); + + // Hook + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'RequestHasSameOriginSecurity' => [ function () { + return false; + } ] + ] ); + $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) ); + $this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' ); + } + + /** + * Test proper creation of the ApiErrorFormatter + * + * @dataProvider provideApiErrorFormatterCreation + * @param array $request Request parameters + * @param array $expect Expected data + * - uselang: ApiMain language + * - class: ApiErrorFormatter class + * - lang: ApiErrorFormatter language + * - format: ApiErrorFormatter format + * - usedb: ApiErrorFormatter use-database flag + */ + public function testApiErrorFormatterCreation( array $request, array $expect ) { + $context = new RequestContext(); + $context->setRequest( new FauxRequest( $request ) ); + $context->setLanguage( 'ru' ); + + $main = new ApiMain( $context ); + $formatter = $main->getErrorFormatter(); + $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter ); + + $this->assertSame( $expect['uselang'], $main->getLanguage()->getCode() ); + $this->assertInstanceOf( $expect['class'], $formatter ); + $this->assertSame( $expect['lang'], $formatter->getLanguage()->getCode() ); + $this->assertSame( $expect['format'], $wrappedFormatter->format ); + $this->assertSame( $expect['usedb'], $wrappedFormatter->useDB ); + } + + public static function provideApiErrorFormatterCreation() { + return [ + 'Default (BC)' => [ [], [ + 'uselang' => 'ru', + 'class' => ApiErrorFormatter_BackCompat::class, + 'lang' => 'en', + 'format' => 'none', + 'usedb' => false, + ] ], + 'BC ignores fields' => [ [ 'errorlang' => 'de', 'errorsuselocal' => 1 ], [ + 'uselang' => 'ru', + 'class' => ApiErrorFormatter_BackCompat::class, + 'lang' => 'en', + 'format' => 'none', + 'usedb' => false, + ] ], + 'Explicit BC' => [ [ 'errorformat' => 'bc' ], [ + 'uselang' => 'ru', + 'class' => ApiErrorFormatter_BackCompat::class, + 'lang' => 'en', + 'format' => 'none', + 'usedb' => false, + ] ], + 'Basic' => [ [ 'errorformat' => 'wikitext' ], [ + 'uselang' => 'ru', + 'class' => ApiErrorFormatter::class, + 'lang' => 'ru', + 'format' => 'wikitext', + 'usedb' => false, + ] ], + 'Follows uselang' => [ [ 'uselang' => 'fr', 'errorformat' => 'plaintext' ], [ + 'uselang' => 'fr', + 'class' => ApiErrorFormatter::class, + 'lang' => 'fr', + 'format' => 'plaintext', + 'usedb' => false, + ] ], + 'Explicitly follows uselang' => [ + [ 'uselang' => 'fr', 'errorlang' => 'uselang', 'errorformat' => 'plaintext' ], + [ + 'uselang' => 'fr', + 'class' => ApiErrorFormatter::class, + 'lang' => 'fr', + 'format' => 'plaintext', + 'usedb' => false, + ] + ], + 'uselang=content' => [ + [ 'uselang' => 'content', 'errorformat' => 'plaintext' ], + [ + 'uselang' => 'en', + 'class' => ApiErrorFormatter::class, + 'lang' => 'en', + 'format' => 'plaintext', + 'usedb' => false, + ] + ], + 'errorlang=content' => [ + [ 'errorlang' => 'content', 'errorformat' => 'plaintext' ], + [ + 'uselang' => 'ru', + 'class' => ApiErrorFormatter::class, + 'lang' => 'en', + 'format' => 'plaintext', + 'usedb' => false, + ] + ], + 'Explicit parameters' => [ + [ 'errorlang' => 'de', 'errorformat' => 'html', 'errorsuselocal' => 1 ], + [ + 'uselang' => 'ru', + 'class' => ApiErrorFormatter::class, + 'lang' => 'de', + 'format' => 'html', + 'usedb' => true, + ] + ], + 'Explicit parameters override uselang' => [ + [ 'errorlang' => 'de', 'uselang' => 'fr', 'errorformat' => 'raw' ], + [ + 'uselang' => 'fr', + 'class' => ApiErrorFormatter::class, + 'lang' => 'de', + 'format' => 'raw', + 'usedb' => false, + ] + ], + 'Bogus language doesn\'t explode' => [ + [ 'errorlang' => '<bogus1>', 'uselang' => '<bogus2>', 'errorformat' => 'none' ], + [ + 'uselang' => 'en', + 'class' => ApiErrorFormatter::class, + 'lang' => 'en', + 'format' => 'none', + 'usedb' => false, + ] + ], + 'Bogus format doesn\'t explode' => [ [ 'errorformat' => 'bogus' ], [ + 'uselang' => 'ru', + 'class' => ApiErrorFormatter_BackCompat::class, + 'lang' => 'en', + 'format' => 'none', + 'usedb' => false, + ] ], + ]; + } + + /** + * @dataProvider provideExceptionErrors + * @param Exception $exception + * @param array $expectReturn + * @param array $expectResult + */ + public function testExceptionErrors( $error, $expectReturn, $expectResult ) { + $context = new RequestContext(); + $context->setRequest( new FauxRequest( [ 'errorformat' => 'plaintext' ] ) ); + $context->setLanguage( 'en' ); + $context->setConfig( new MultiConfig( [ + new HashConfig( [ + 'ShowHostnames' => true, 'ShowSQLErrors' => false, + 'ShowExceptionDetails' => true, 'ShowDBErrorBacktrace' => true, + ] ), + $context->getConfig() + ] ) ); + + $main = new ApiMain( $context ); + $main->addWarning( new RawMessage( 'existing warning' ), 'existing-warning' ); + $main->addError( new RawMessage( 'existing error' ), 'existing-error' ); + + $ret = TestingAccessWrapper::newFromObject( $main )->substituteResultWithError( $error ); + $this->assertSame( $expectReturn, $ret ); + + // PHPUnit sometimes adds some SplObjectStorage garbage to the arrays, + // so let's try ->assertEquals(). + $this->assertEquals( + $expectResult, + $main->getResult()->getResultData( [], [ 'Strip' => 'all' ] ) + ); + } + + // Not static so $this can be used + public function provideExceptionErrors() { + $reqId = WebRequest::getRequestId(); + $doclink = wfExpandUrl( wfScript( 'api' ) ); + + $ex = new InvalidArgumentException( 'Random exception' ); + $trace = wfMessage( 'api-exception-trace', + get_class( $ex ), + $ex->getFile(), + $ex->getLine(), + MWExceptionHandler::getRedactedTraceAsString( $ex ) + )->inLanguage( 'en' )->useDatabase( false )->text(); + + $dbex = new DBQueryError( + $this->createMock( \Wikimedia\Rdbms\IDatabase::class ), + 'error', 1234, 'SELECT 1', __METHOD__ ); + $dbtrace = wfMessage( 'api-exception-trace', + get_class( $dbex ), + $dbex->getFile(), + $dbex->getLine(), + MWExceptionHandler::getRedactedTraceAsString( $dbex ) + )->inLanguage( 'en' )->useDatabase( false )->text(); + + Wikimedia\suppressWarnings(); + $usageEx = new UsageException( 'Usage exception!', 'ue', 0, [ 'foo' => 'bar' ] ); + Wikimedia\restoreWarnings(); + + $apiEx1 = new ApiUsageException( null, + StatusValue::newFatal( new ApiRawMessage( 'An error', 'sv-error1' ) ) ); + TestingAccessWrapper::newFromObject( $apiEx1 )->modulePath = 'foo+bar'; + $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'A warning', 'sv-warn1' ) ); + $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'Another warning', 'sv-warn2' ) ); + $apiEx1->getStatusValue()->fatal( new ApiRawMessage( 'Another error', 'sv-error2' ) ); + + return [ + [ + $ex, + [ 'existing-error', 'internal_api_error_InvalidArgumentException' ], + [ + 'warnings' => [ + [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ], + ], + 'errors' => [ + [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ], + [ + 'code' => 'internal_api_error_InvalidArgumentException', + 'text' => "[$reqId] Exception caught: Random exception", + ] + ], + 'trace' => $trace, + 'servedby' => wfHostname(), + ] + ], + [ + $dbex, + [ 'existing-error', 'internal_api_error_DBQueryError' ], + [ + 'warnings' => [ + [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ], + ], + 'errors' => [ + [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ], + [ + 'code' => 'internal_api_error_DBQueryError', + 'text' => "[$reqId] Database query error.", + ] + ], + 'trace' => $dbtrace, + 'servedby' => wfHostname(), + ] + ], + [ + $usageEx, + [ 'existing-error', 'ue' ], + [ + 'warnings' => [ + [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ], + ], + 'errors' => [ + [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ], + [ 'code' => 'ue', 'text' => "Usage exception!", 'data' => [ 'foo' => 'bar' ] ] + ], + 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " . + "list at <https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce> " . + "for notice of API deprecations and breaking changes.", + 'servedby' => wfHostname(), + ] + ], + [ + $apiEx1, + [ 'existing-error', 'sv-error1', 'sv-error2' ], + [ + 'warnings' => [ + [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ], + [ 'code' => 'sv-warn1', 'text' => 'A warning', 'module' => 'foo+bar' ], + [ 'code' => 'sv-warn2', 'text' => 'Another warning', 'module' => 'foo+bar' ], + ], + 'errors' => [ + [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ], + [ 'code' => 'sv-error1', 'text' => 'An error', 'module' => 'foo+bar' ], + [ 'code' => 'sv-error2', 'text' => 'Another error', 'module' => 'foo+bar' ], + ], + 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " . + "list at <https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce> " . + "for notice of API deprecations and breaking changes.", + 'servedby' => wfHostname(), + ] + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php b/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php new file mode 100644 index 00000000..c6f5a8e7 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php @@ -0,0 +1,189 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group API + */ +class ApiMessageTest extends MediaWikiTestCase { + + private function compareMessages( Message $msg, Message $msg2 ) { + $this->assertSame( $msg->getKey(), $msg2->getKey(), 'getKey' ); + $this->assertSame( $msg->getKeysToTry(), $msg2->getKeysToTry(), 'getKeysToTry' ); + $this->assertSame( $msg->getParams(), $msg2->getParams(), 'getParams' ); + $this->assertSame( $msg->getLanguage(), $msg2->getLanguage(), 'getLanguage' ); + + $msg = TestingAccessWrapper::newFromObject( $msg ); + $msg2 = TestingAccessWrapper::newFromObject( $msg2 ); + $this->assertSame( $msg->interface, $msg2->interface, 'interface' ); + $this->assertSame( $msg->useDatabase, $msg2->useDatabase, 'useDatabase' ); + $this->assertSame( $msg->format, $msg2->format, 'format' ); + $this->assertSame( + $msg->title ? $msg->title->getFullText() : null, + $msg2->title ? $msg2->title->getFullText() : null, + 'title' + ); + } + + /** + * @covers ApiMessageTrait + */ + public function testCodeDefaults() { + $msg = new ApiMessage( 'foo' ); + $this->assertSame( 'foo', $msg->getApiCode() ); + + $msg = new ApiMessage( 'apierror-bar' ); + $this->assertSame( 'bar', $msg->getApiCode() ); + + $msg = new ApiMessage( 'apiwarn-baz' ); + $this->assertSame( 'baz', $msg->getApiCode() ); + + // BC case + $msg = new ApiMessage( 'actionthrottledtext' ); + $this->assertSame( 'ratelimited', $msg->getApiCode() ); + + $msg = new ApiMessage( [ 'apierror-missingparam', 'param' ] ); + $this->assertSame( 'noparam', $msg->getApiCode() ); + } + + /** + * @covers ApiMessageTrait + * @dataProvider provideInvalidCode + * @param mixed $code + */ + public function testInvalidCode( $code ) { + $msg = new ApiMessage( 'foo' ); + try { + $msg->setApiCode( $code ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertTrue( true ); + } + + try { + new ApiMessage( 'foo', $code ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertTrue( true ); + } + } + + public static function provideInvalidCode() { + return [ + [ '' ], + [ 42 ], + ]; + } + + /** + * @covers ApiMessage + * @covers ApiMessageTrait + */ + public function testApiMessage() { + $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] ); + $msg->inLanguage( 'de' )->title( Title::newMainPage() ); + $msg2 = new ApiMessage( $msg, 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg2 = unserialize( serialize( $msg2 ) ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] ); + $msg2 = new ApiMessage( [ [ 'foo', 'bar' ], 'baz' ], 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new Message( 'foo' ); + $msg2 = new ApiMessage( 'foo' ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'foo', $msg2->getApiCode() ); + $this->assertEquals( [], $msg2->getApiData() ); + + $msg2->setApiCode( 'code', [ 'data' ] ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiCode( null ); + $this->assertEquals( 'foo', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiData( [ 'data2' ] ); + $this->assertEquals( [ 'data2' ], $msg2->getApiData() ); + } + + /** + * @covers ApiRawMessage + * @covers ApiMessageTrait + */ + public function testApiRawMessage() { + $msg = new RawMessage( 'foo', [ 'baz' ] ); + $msg->inLanguage( 'de' )->title( Title::newMainPage() ); + $msg2 = new ApiRawMessage( $msg, 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg2 = unserialize( serialize( $msg2 ) ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new RawMessage( 'foo', [ 'baz' ] ); + $msg2 = new ApiRawMessage( [ 'foo', 'baz' ], 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg = new RawMessage( 'foo' ); + $msg2 = new ApiRawMessage( 'foo', 'code', [ 'data' ] ); + $this->compareMessages( $msg, $msg2 ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + + $msg2->setApiCode( 'code', [ 'data' ] ); + $this->assertEquals( 'code', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiCode( null ); + $this->assertEquals( 'foo', $msg2->getApiCode() ); + $this->assertEquals( [ 'data' ], $msg2->getApiData() ); + $msg2->setApiData( [ 'data2' ] ); + $this->assertEquals( [ 'data2' ], $msg2->getApiData() ); + } + + /** + * @covers ApiMessage::create + */ + public function testApiMessageCreate() { + $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( new Message( 'mainpage' ) ) ); + $this->assertInstanceOf( + ApiRawMessage::class, ApiMessage::create( new RawMessage( 'mainpage' ) ) + ); + $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( 'mainpage' ) ); + + $msg = new ApiMessage( [ 'parentheses', 'foobar' ] ); + $msg2 = new Message( 'parentheses', [ 'foobar' ] ); + + $this->assertSame( $msg, ApiMessage::create( $msg ) ); + $this->assertEquals( $msg, ApiMessage::create( $msg2 ) ); + $this->assertEquals( $msg, ApiMessage::create( [ 'parentheses', 'foobar' ] ) ); + $this->assertEquals( $msg, + ApiMessage::create( [ 'message' => 'parentheses', 'params' => [ 'foobar' ] ] ) + ); + $this->assertSame( $msg, + ApiMessage::create( [ 'message' => $msg, 'params' => [ 'xxx' ] ] ) + ); + $this->assertEquals( $msg, + ApiMessage::create( [ 'message' => $msg2, 'params' => [ 'xxx' ] ] ) + ); + $this->assertSame( $msg, + ApiMessage::create( [ 'message' => $msg ] ) + ); + + $msg = new ApiRawMessage( [ 'parentheses', 'foobar' ] ); + $this->assertSame( $msg, ApiMessage::create( $msg ) ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php b/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php new file mode 100644 index 00000000..b01b90e8 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php @@ -0,0 +1,330 @@ +<?php + +/** + * @covers ApiModuleManager + * + * @group API + * @group Database + * @group medium + */ +class ApiModuleManagerTest extends MediaWikiTestCase { + + private function getModuleManager() { + $request = new FauxRequest(); + $main = new ApiMain( $request ); + return new ApiModuleManager( $main ); + } + + public function newApiLogin( $main, $action ) { + return new ApiLogin( $main, $action ); + } + + public function addModuleProvider() { + return [ + 'plain class' => [ + 'login', + 'action', + ApiLogin::class, + null, + ], + + 'with factory' => [ + 'login', + 'action', + ApiLogin::class, + [ $this, 'newApiLogin' ], + ], + + 'with closure' => [ + 'logout', + 'action', + ApiLogout::class, + function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ], + ]; + } + + /** + * @dataProvider addModuleProvider + */ + public function testAddModule( $name, $group, $class, $factory = null ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModule( $name, $group, $class, $factory ); + + $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' ); + $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' ); + } + + public function addModulesProvider() { + return [ + 'empty' => [ + [], + 'action', + ], + + 'simple' => [ + [ + 'login' => ApiLogin::class, + 'logout' => ApiLogout::class, + ], + 'action', + ], + + 'with factories' => [ + [ + 'login' => [ + 'class' => ApiLogin::class, + 'factory' => [ $this, 'newApiLogin' ], + ], + 'logout' => [ + 'class' => ApiLogout::class, + 'factory' => function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ], + ], + 'action', + ], + ]; + } + + /** + * @dataProvider addModulesProvider + */ + public function testAddModules( array $modules, $group ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, $group ); + + foreach ( array_keys( $modules ) as $name ) { + $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' ); + $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' ); + } + + $this->assertTrue( true ); // Don't mark the test as risky if $modules is empty + } + + public function getModuleProvider() { + $modules = [ + 'feedrecentchanges' => ApiFeedRecentChanges::class, + 'feedcontributions' => [ 'class' => ApiFeedContributions::class ], + 'login' => [ + 'class' => ApiLogin::class, + 'factory' => [ $this, 'newApiLogin' ], + ], + 'logout' => [ + 'class' => ApiLogout::class, + 'factory' => function ( ApiMain $main, $action ) { + return new ApiLogout( $main, $action ); + }, + ], + ]; + + return [ + 'legacy entry' => [ + $modules, + 'feedrecentchanges', + ApiFeedRecentChanges::class, + ], + + 'just a class' => [ + $modules, + 'feedcontributions', + ApiFeedContributions::class, + ], + + 'with factory' => [ + $modules, + 'login', + ApiLogin::class, + ], + + 'with closure' => [ + $modules, + 'logout', + ApiLogout::class, + ], + ]; + } + + /** + * @covers ApiModuleManager::getModule + * @dataProvider getModuleProvider + */ + public function testGetModule( $modules, $name, $expectedClass ) { + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, 'test' ); + + // should return the right module + $module1 = $moduleManager->getModule( $name, null, false ); + $this->assertInstanceOf( $expectedClass, $module1 ); + + // should pass group check (with caching disabled) + $module2 = $moduleManager->getModule( $name, 'test', true ); + $this->assertNotNull( $module2 ); + + // should use cached instance + $module3 = $moduleManager->getModule( $name, null, false ); + $this->assertSame( $module1, $module3 ); + + // should not use cached instance if caching is disabled + $module4 = $moduleManager->getModule( $name, null, true ); + $this->assertNotSame( $module1, $module4 ); + } + + /** + * @covers ApiModuleManager::getModule + */ + public function testGetModule_null() { + $modules = [ + 'login' => ApiLogin::class, + 'logout' => ApiLogout::class, + ]; + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $modules, 'test' ); + + $this->assertNull( $moduleManager->getModule( 'quux' ), 'unknown name' ); + $this->assertNull( $moduleManager->getModule( 'login', 'bla' ), 'wrong group' ); + } + + /** + * @covers ApiModuleManager::getNames + */ + public function testGetNames() { + $fooModules = [ + 'login' => ApiLogin::class, + 'logout' => ApiLogout::class, + ]; + + $barModules = [ + 'feedcontributions' => [ 'class' => ApiFeedContributions::class ], + 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ], + ]; + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $fooNames = $moduleManager->getNames( 'foo' ); + $this->assertArrayEquals( array_keys( $fooModules ), $fooNames ); + + $allNames = $moduleManager->getNames(); + $allModules = array_merge( $fooModules, $barModules ); + $this->assertArrayEquals( array_keys( $allModules ), $allNames ); + } + + /** + * @covers ApiModuleManager::getNamesWithClasses + */ + public function testGetNamesWithClasses() { + $fooModules = [ + 'login' => ApiLogin::class, + 'logout' => ApiLogout::class, + ]; + + $barModules = [ + 'feedcontributions' => [ 'class' => ApiFeedContributions::class ], + 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ], + ]; + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $fooNamesWithClasses = $moduleManager->getNamesWithClasses( 'foo' ); + $this->assertArrayEquals( $fooModules, $fooNamesWithClasses ); + + $allNamesWithClasses = $moduleManager->getNamesWithClasses(); + $allModules = array_merge( $fooModules, [ + 'feedcontributions' => ApiFeedContributions::class, + 'feedrecentchanges' => ApiFeedRecentChanges::class, + ] ); + $this->assertArrayEquals( $allModules, $allNamesWithClasses ); + } + + /** + * @covers ApiModuleManager::getModuleGroup + */ + public function testGetModuleGroup() { + $fooModules = [ + 'login' => ApiLogin::class, + 'logout' => ApiLogout::class, + ]; + + $barModules = [ + 'feedcontributions' => [ 'class' => ApiFeedContributions::class ], + 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ], + ]; + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $this->assertEquals( 'foo', $moduleManager->getModuleGroup( 'login' ) ); + $this->assertEquals( 'bar', $moduleManager->getModuleGroup( 'feedrecentchanges' ) ); + $this->assertNull( $moduleManager->getModuleGroup( 'quux' ) ); + } + + /** + * @covers ApiModuleManager::getGroups + */ + public function testGetGroups() { + $fooModules = [ + 'login' => ApiLogin::class, + 'logout' => ApiLogout::class, + ]; + + $barModules = [ + 'feedcontributions' => [ 'class' => ApiFeedContributions::class ], + 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ], + ]; + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $groups = $moduleManager->getGroups(); + $this->assertArrayEquals( [ 'foo', 'bar' ], $groups ); + } + + /** + * @covers ApiModuleManager::getClassName + */ + public function testGetClassName() { + $fooModules = [ + 'login' => ApiLogin::class, + 'logout' => ApiLogout::class, + ]; + + $barModules = [ + 'feedcontributions' => [ 'class' => ApiFeedContributions::class ], + 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ], + ]; + + $moduleManager = $this->getModuleManager(); + $moduleManager->addModules( $fooModules, 'foo' ); + $moduleManager->addModules( $barModules, 'bar' ); + + $this->assertEquals( + ApiLogin::class, + $moduleManager->getClassName( 'login' ) + ); + $this->assertEquals( + ApiLogout::class, + $moduleManager->getClassName( 'logout' ) + ); + $this->assertEquals( + ApiFeedContributions::class, + $moduleManager->getClassName( 'feedcontributions' ) + ); + $this->assertEquals( + ApiFeedRecentChanges::class, + $moduleManager->getClassName( 'feedrecentchanges' ) + ); + $this->assertFalse( + $moduleManager->getClassName( 'nonexistentmodule' ) + ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php b/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php new file mode 100644 index 00000000..fb697ffd --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php @@ -0,0 +1,393 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiMove + */ +class ApiMoveTest extends ApiTestCase { + /** + * @param string $from Prefixed name of source + * @param string $to Prefixed name of destination + * @param string $id Page id of the page to move + * @param array|string|null $opts Options: 'noredirect' to expect no redirect + */ + protected function assertMoved( $from, $to, $id, $opts = null ) { + $opts = (array)$opts; + + $fromTitle = Title::newFromText( $from ); + $toTitle = Title::newFromText( $to ); + + $this->assertTrue( $toTitle->exists(), + "Destination {$toTitle->getPrefixedText()} does not exist" ); + + if ( in_array( 'noredirect', $opts ) ) { + $this->assertFalse( $fromTitle->exists(), + "Source {$fromTitle->getPrefixedText()} exists" ); + } else { + $this->assertTrue( $fromTitle->exists(), + "Source {$fromTitle->getPrefixedText()} does not exist" ); + $this->assertTrue( $fromTitle->isRedirect(), + "Source {$fromTitle->getPrefixedText()} is not a redirect" ); + + $target = Revision::newFromTitle( $fromTitle )->getContent()->getRedirectTarget(); + $this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() ); + } + + $this->assertSame( $id, $toTitle->getArticleId() ); + } + + /** + * Shortcut function to create a page and return its id. + * + * @param string $name Page to create + * @return int ID of created page + */ + protected function createPage( $name ) { + return $this->editPage( $name, 'Content' )->value['revision']->getPage(); + } + + public function testFromWithFromid() { + $this->setExpectedException( ApiUsageException::class, + 'The parameters "from" and "fromid" can not be used together.' ); + + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => 'Some page', + 'fromid' => 123, + 'to' => 'Some other page', + ] ); + } + + public function testMove() { + $name = ucfirst( __FUNCTION__ ); + + $id = $this->createPage( $name ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + ] ); + + $this->assertMoved( $name, "$name 2", $id ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testMoveById() { + $name = ucfirst( __FUNCTION__ ); + + $id = $this->createPage( $name ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'fromid' => $id, + 'to' => "$name 2", + ] ); + + $this->assertMoved( $name, "$name 2", $id ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testMoveNonexistent() { + $this->setExpectedException( ApiUsageException::class, + "The page you specified doesn't exist." ); + + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => 'Nonexistent page', + 'to' => 'Different page' + ] ); + } + + public function testMoveNonexistentId() { + $this->setExpectedException( ApiUsageException::class, + 'There is no page with ID 2147483647.' ); + + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'fromid' => pow( 2, 31 ) - 1, + 'to' => 'Different page', + ] ); + } + + public function testMoveToInvalidPageName() { + $this->setExpectedException( ApiUsageException::class, 'Bad title "[".' ); + + $name = ucfirst( __FUNCTION__ ); + $id = $this->createPage( $name ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => '[', + ] ); + } finally { + $this->assertSame( $id, Title::newFromText( $name )->getArticleId() ); + } + } + + // @todo File moving + + public function testPingLimiter() { + global $wgRateLimits; + + $this->setExpectedException( ApiUsageException::class, + "You've exceeded your rate limit. Please wait some time and try again." ); + + $name = ucfirst( __FUNCTION__ ); + + $this->setMwGlobals( 'wgMainCacheType', 'hash' ); + + $this->stashMwGlobals( 'wgRateLimits' ); + $wgRateLimits['move'] = [ '&can-bypass' => false, 'user' => [ 1, 60 ] ]; + + $id = $this->createPage( $name ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + ] ); + + $this->assertMoved( $name, "$name 2", $id ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => "$name 2", + 'to' => "$name 3", + ] ); + } finally { + $this->assertSame( $id, Title::newFromText( "$name 2" )->getArticleId() ); + $this->assertFalse( Title::newFromText( "$name 3" )->exists(), + "\"$name 3\" should not exist" ); + } + } + + public function testTagsNoPermission() { + $this->setExpectedException( ApiUsageException::class, + 'You do not have permission to apply change tags along with your changes.' ); + + $name = ucfirst( __FUNCTION__ ); + + ChangeTags::defineTag( 'custom tag' ); + + $this->setGroupPermissions( 'user', 'applychangetags', false ); + + $id = $this->createPage( $name ); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + 'tags' => 'custom tag', + ] ); + } finally { + $this->assertSame( $id, Title::newFromText( $name )->getArticleId() ); + $this->assertFalse( Title::newFromText( "$name 2" )->exists(), + "\"$name 2\" should not exist" ); + } + } + + public function testSelfMove() { + $this->setExpectedException( ApiUsageException::class, + 'The title is the same; cannot move a page over itself.' ); + + $name = ucfirst( __FUNCTION__ ); + $this->createPage( $name ); + + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => $name, + ] ); + } + + public function testMoveTalk() { + $name = ucfirst( __FUNCTION__ ); + + $id = $this->createPage( $name ); + $talkId = $this->createPage( "Talk:$name" ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + 'movetalk' => '', + ] ); + + $this->assertMoved( $name, "$name 2", $id ); + $this->assertMoved( "Talk:$name", "Talk:$name 2", $talkId ); + + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testMoveTalkFailed() { + $name = ucfirst( __FUNCTION__ ); + + $id = $this->createPage( $name ); + $talkId = $this->createPage( "Talk:$name" ); + $talkDestinationId = $this->createPage( "Talk:$name 2" ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + 'movetalk' => '', + ] ); + + $this->assertMoved( $name, "$name 2", $id ); + $this->assertSame( $talkId, Title::newFromText( "Talk:$name" )->getArticleId() ); + $this->assertSame( $talkDestinationId, + Title::newFromText( "Talk:$name 2" )->getArticleId() ); + $this->assertSame( [ [ + 'message' => 'articleexists', + 'params' => [], + 'code' => 'articleexists', + 'type' => 'error', + ] ], $res[0]['move']['talkmove-errors'] ); + + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testMoveSubpages() { + global $wgNamespacesWithSubpages; + + $name = ucfirst( __FUNCTION__ ); + + $this->stashMwGlobals( 'wgNamespacesWithSubpages' ); + $wgNamespacesWithSubpages[NS_MAIN] = true; + + $pages = [ $name, "$name/1", "$name/2", "Talk:$name", "Talk:$name/1", "Talk:$name/3" ]; + $ids = []; + foreach ( array_merge( $pages, [ "$name/error", "$name 2/error" ] ) as $page ) { + $ids[$page] = $this->createPage( $page ); + } + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + 'movetalk' => '', + 'movesubpages' => '', + ] ); + + foreach ( $pages as $page ) { + $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] ); + } + + $this->assertSame( $ids["$name/error"], + Title::newFromText( "$name/error" )->getArticleId() ); + $this->assertSame( $ids["$name 2/error"], + Title::newFromText( "$name 2/error" )->getArticleId() ); + + $results = array_merge( $res[0]['move']['subpages'], $res[0]['move']['subpages-talk'] ); + foreach ( $results as $arr ) { + if ( $arr['from'] === "$name/error" ) { + $this->assertSame( [ [ + 'message' => 'articleexists', + 'params' => [], + 'code' => 'articleexists', + 'type' => 'error' + ] ], $arr['errors'] ); + } else { + $this->assertSame( str_replace( $name, "$name 2", $arr['from'] ), $arr['to'] ); + } + $this->assertCount( 2, $arr ); + } + + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testMoveNoPermission() { + $this->setExpectedException( ApiUsageException::class, + 'You must be a registered user and [[Special:UserLogin|logged in]] to move a page.' ); + + $name = ucfirst( __FUNCTION__ ); + + $id = $this->createPage( $name ); + + $user = new User(); + + try { + $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + ], null, $user ); + } finally { + $this->assertSame( $id, Title::newFromText( "$name" )->getArticleId() ); + $this->assertFalse( Title::newFromText( "$name 2" )->exists(), + "\"$name 2\" should not exist" ); + } + } + + public function testSuppressRedirect() { + $name = ucfirst( __FUNCTION__ ); + + $id = $this->createPage( $name ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + 'noredirect' => '', + ] ); + + $this->assertMoved( $name, "$name 2", $id, 'noredirect' ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testSuppressRedirectNoPermission() { + $name = ucfirst( __FUNCTION__ ); + + $this->setGroupPermissions( 'sysop', 'suppressredirect', false ); + + $id = $this->createPage( $name ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => $name, + 'to' => "$name 2", + 'noredirect' => '', + ] ); + + $this->assertMoved( $name, "$name 2", $id ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testMoveSubpagesError() { + $name = ucfirst( __FUNCTION__ ); + + // Subpages are allowed in talk but not main + $idBase = $this->createPage( "Talk:$name" ); + $idSub = $this->createPage( "Talk:$name/1" ); + + $res = $this->doApiRequestWithToken( [ + 'action' => 'move', + 'from' => "Talk:$name", + 'to' => $name, + 'movesubpages' => '', + ] ); + + $this->assertMoved( "Talk:$name", $name, $idBase ); + $this->assertSame( $idSub, Title::newFromText( "Talk:$name/1" )->getArticleId() ); + $this->assertFalse( Title::newFromText( "$name/1" )->exists(), + "\"$name/1\" should not exist" ); + + $this->assertSame( [ 'errors' => [ [ + 'message' => 'namespace-nosubpages', + 'params' => [ '' ], + 'code' => 'namespace-nosubpages', + 'type' => 'error', + ] ] ], $res[0]['move']['subpages'] ); + + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php b/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php new file mode 100644 index 00000000..209ca07b --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php @@ -0,0 +1,67 @@ +<?php + +/** + * @covers ApiOpenSearch + */ +class ApiOpenSearchTest extends MediaWikiTestCase { + public function testGetAllowedParams() { + $config = $this->replaceSearchEngineConfig(); + $config->expects( $this->any() ) + ->method( 'getSearchTypes' ) + ->will( $this->returnValue( [ 'the one ring' ] ) ); + + $api = $this->createApi(); + $engine = $this->replaceSearchEngine(); + $engine->expects( $this->any() ) + ->method( 'getProfiles' ) + ->will( $this->returnValueMap( [ + [ SearchEngine::COMPLETION_PROFILE_TYPE, $api->getUser(), [ + [ + 'name' => 'normal', + 'desc-message' => 'normal-message', + 'default' => true, + ], + [ + 'name' => 'strict', + 'desc-message' => 'strict-message', + ], + ] ], + ] ) ); + + $params = $api->getAllowedParams(); + + $this->assertArrayNotHasKey( 'offset', $params ); + $this->assertArrayHasKey( 'profile', $params, print_r( $params, true ) ); + $this->assertEquals( 'normal', $params['profile'][ApiBase::PARAM_DFLT] ); + } + + private function replaceSearchEngineConfig() { + $config = $this->getMockBuilder( SearchEngineConfig::class ) + ->disableOriginalConstructor() + ->getMock(); + $this->setService( 'SearchEngineConfig', $config ); + + return $config; + } + + private function replaceSearchEngine() { + $engine = $this->getMockBuilder( SearchEngine::class ) + ->disableOriginalConstructor() + ->getMock(); + $engineFactory = $this->getMockBuilder( SearchEngineFactory::class ) + ->disableOriginalConstructor() + ->getMock(); + $engineFactory->expects( $this->any() ) + ->method( 'create' ) + ->will( $this->returnValue( $engine ) ); + $this->setService( 'SearchEngineFactory', $engineFactory ); + + return $engine; + } + + private function createApi() { + $ctx = new RequestContext(); + $apiMain = new ApiMain( $ctx ); + return new ApiOpenSearch( $apiMain, 'opensearch', '' ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php b/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php new file mode 100644 index 00000000..c0fecf06 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php @@ -0,0 +1,418 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiOptions + */ +class ApiOptionsTest extends MediaWikiLangTestCase { + + /** @var PHPUnit_Framework_MockObject_MockObject */ + private $mUserMock; + /** @var ApiOptions */ + private $mTested; + private $mSession; + /** @var DerivativeContext */ + private $mContext; + + private static $Success = [ 'options' => 'success' ]; + + protected function setUp() { + parent::setUp(); + + $this->mUserMock = $this->getMockBuilder( User::class ) + ->disableOriginalConstructor() + ->getMock(); + + // Set up groups and rights + $this->mUserMock->expects( $this->any() ) + ->method( 'getEffectiveGroups' )->will( $this->returnValue( [ '*', 'user' ] ) ); + $this->mUserMock->expects( $this->any() ) + ->method( 'isAllowedAny' )->will( $this->returnValue( true ) ); + + // Set up callback for User::getOptionKinds + $this->mUserMock->expects( $this->any() ) + ->method( 'getOptionKinds' )->will( $this->returnCallback( [ $this, 'getOptionKinds' ] ) ); + + // No actual DB data + $this->mUserMock->expects( $this->any() ) + ->method( 'getInstanceForUpdate' )->will( $this->returnValue( $this->mUserMock ) ); + + // Create a new context + $this->mContext = new DerivativeContext( new RequestContext() ); + $this->mContext->getContext()->setTitle( Title::newFromText( 'Test' ) ); + $this->mContext->setUser( $this->mUserMock ); + + $main = new ApiMain( $this->mContext ); + + // Empty session + $this->mSession = []; + + $this->mTested = new ApiOptions( $main, 'options' ); + + $this->mergeMwGlobalArrayValue( 'wgHooks', [ + 'GetPreferences' => [ + [ $this, 'hookGetPreferences' ] + ] + ] ); + } + + public function hookGetPreferences( $user, &$preferences ) { + $preferences = []; + + foreach ( [ 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ] as $k ) { + $preferences[$k] = [ + 'type' => 'text', + 'section' => 'test', + 'label' => ' ', + ]; + } + + $preferences['testmultiselect'] = [ + 'type' => 'multiselect', + 'options' => [ + 'Test' => [ + '<span dir="auto">Some HTML here for option 1</span>' => 'opt1', + '<span dir="auto">Some HTML here for option 2</span>' => 'opt2', + '<span dir="auto">Some HTML here for option 3</span>' => 'opt3', + '<span dir="auto">Some HTML here for option 4</span>' => 'opt4', + ], + ], + 'section' => 'test', + 'label' => ' ', + 'prefix' => 'testmultiselect-', + 'default' => [], + ]; + + return true; + } + + /** + * @param IContextSource $context + * @param array|null $options + * + * @return array + */ + public function getOptionKinds( IContextSource $context, $options = null ) { + // Match with above. + $kinds = [ + 'name' => 'registered', + 'willBeNull' => 'registered', + 'willBeEmpty' => 'registered', + 'willBeHappy' => 'registered', + 'testmultiselect-opt1' => 'registered-multiselect', + 'testmultiselect-opt2' => 'registered-multiselect', + 'testmultiselect-opt3' => 'registered-multiselect', + 'testmultiselect-opt4' => 'registered-multiselect', + 'special' => 'special', + ]; + + if ( $options === null ) { + return $kinds; + } + + $mapping = []; + foreach ( $options as $key => $value ) { + if ( isset( $kinds[$key] ) ) { + $mapping[$key] = $kinds[$key]; + } elseif ( substr( $key, 0, 7 ) === 'userjs-' ) { + $mapping[$key] = 'userjs'; + } else { + $mapping[$key] = 'unused'; + } + } + + return $mapping; + } + + private function getSampleRequest( $custom = [] ) { + $request = [ + 'token' => '123ABC', + 'change' => null, + 'optionname' => null, + 'optionvalue' => null, + ]; + + return array_merge( $request, $custom ); + } + + private function executeQuery( $request ) { + $this->mContext->setRequest( new FauxRequest( $request, true, $this->mSession ) ); + $this->mTested->execute(); + + return $this->mTested->getResult()->getResultData( null, [ 'Strip' => 'all' ] ); + } + + /** + * @expectedException ApiUsageException + */ + public function testNoToken() { + $request = $this->getSampleRequest( [ 'token' => null ] ); + + $this->executeQuery( $request ); + } + + public function testAnon() { + $this->mUserMock->expects( $this->once() ) + ->method( 'isAnon' ) + ->will( $this->returnValue( true ) ); + + try { + $request = $this->getSampleRequest(); + + $this->executeQuery( $request ); + } catch ( ApiUsageException $e ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'notloggedin' ) ); + return; + } + $this->fail( "ApiUsageException was not thrown" ); + } + + public function testNoOptionname() { + try { + $request = $this->getSampleRequest( [ 'optionvalue' => '1' ] ); + + $this->executeQuery( $request ); + } catch ( ApiUsageException $e ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'nooptionname' ) ); + return; + } + $this->fail( "ApiUsageException was not thrown" ); + } + + public function testNoChanges() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + try { + $request = $this->getSampleRequest(); + + $this->executeQuery( $request ); + } catch ( ApiUsageException $e ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'nochanges' ) ); + return; + } + $this->fail( "ApiUsageException was not thrown" ); + } + + public function testReset() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ) + ->with( $this->equalTo( [ 'all' ] ) ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ 'reset' => '' ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testResetKinds() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ) + ->with( $this->equalTo( [ 'registered' ] ) ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'setOption' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ 'reset' => '', 'resetkinds' => 'registered' ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testOptionWithValue() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ 'optionname' => 'name', 'optionvalue' => 'value' ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testOptionResetValue() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'name' ), $this->identicalTo( null ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ 'optionname' => 'name' ] ); + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testChange() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->exactly( 3 ) ) + ->method( 'setOption' ) + ->withConsecutive( + [ $this->equalTo( 'willBeNull' ), $this->identicalTo( null ) ], + [ $this->equalTo( 'willBeEmpty' ), $this->equalTo( '' ) ], + [ $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ] + ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ + 'change' => 'willBeNull|willBeEmpty=|willBeHappy=Happy' + ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testResetChangeOption() { + $this->mUserMock->expects( $this->once() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->exactly( 2 ) ) + ->method( 'setOption' ) + ->withConsecutive( + [ $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ], + [ $this->equalTo( 'name' ), $this->equalTo( 'value' ) ] + ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $args = [ + 'reset' => '', + 'change' => 'willBeHappy=Happy', + 'optionname' => 'name', + 'optionvalue' => 'value' + ]; + + $response = $this->executeQuery( $this->getSampleRequest( $args ) ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testMultiSelect() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->exactly( 4 ) ) + ->method( 'setOption' ) + ->withConsecutive( + [ $this->equalTo( 'testmultiselect-opt1' ), $this->identicalTo( true ) ], + [ $this->equalTo( 'testmultiselect-opt2' ), $this->identicalTo( null ) ], + [ $this->equalTo( 'testmultiselect-opt3' ), $this->identicalTo( false ) ], + [ $this->equalTo( 'testmultiselect-opt4' ), $this->identicalTo( false ) ] + ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ + 'change' => 'testmultiselect-opt1=1|testmultiselect-opt2|' + . 'testmultiselect-opt3=|testmultiselect-opt4=0' + ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } + + public function testSpecialOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ + 'change' => 'special=1' + ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( [ + 'options' => 'success', + 'warnings' => [ + 'options' => [ + 'warnings' => "Validation error for \"special\": cannot be set by this module." + ] + ] + ], $response ); + } + + public function testUnknownOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->never() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ + 'change' => 'unknownOption=1' + ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( [ + 'options' => 'success', + 'warnings' => [ + 'options' => [ + 'warnings' => "Validation error for \"unknownOption\": not a valid preference." + ] + ] + ], $response ); + } + + public function testUserjsOption() { + $this->mUserMock->expects( $this->never() ) + ->method( 'resetOptions' ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'setOption' ) + ->with( $this->equalTo( 'userjs-option' ), $this->equalTo( '1' ) ); + + $this->mUserMock->expects( $this->once() ) + ->method( 'saveSettings' ); + + $request = $this->getSampleRequest( [ + 'change' => 'userjs-option=1' + ] ); + + $response = $this->executeQuery( $request ); + + $this->assertEquals( self::$Success, $response ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php b/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php new file mode 100644 index 00000000..b9e4645d --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php @@ -0,0 +1,179 @@ +<?php + +/** + * @group API + * @group medium + * @group Database + * @covers ApiPageSet + */ +class ApiPageSetTest extends ApiTestCase { + public static function provideRedirectMergePolicy() { + return [ + 'By default nothing is merged' => [ + null, + [] + ], + + 'A simple merge policy adds the redirect data in' => [ + function ( $current, $new ) { + if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) { + $current['index'] = $new['index']; + } + return $current; + }, + [ 'index' => 1 ], + ], + ]; + } + + /** + * @dataProvider provideRedirectMergePolicy + */ + public function testRedirectMergePolicyWithArrayResult( $mergePolicy, $expect ) { + list( $target, $pageSet ) = $this->createPageSetWithRedirect(); + $pageSet->setRedirectMergePolicy( $mergePolicy ); + $result = [ + $target->getArticleID() => [] + ]; + $pageSet->populateGeneratorData( $result ); + $this->assertEquals( $expect, $result[$target->getArticleID()] ); + } + + /** + * @dataProvider provideRedirectMergePolicy + */ + public function testRedirectMergePolicyWithApiResult( $mergePolicy, $expect ) { + list( $target, $pageSet ) = $this->createPageSetWithRedirect(); + $pageSet->setRedirectMergePolicy( $mergePolicy ); + $result = new ApiResult( false ); + $result->addValue( null, 'pages', [ + $target->getArticleID() => [] + ] ); + $pageSet->populateGeneratorData( $result, [ 'pages' ] ); + $this->assertEquals( + $expect, + $result->getResultData( [ 'pages', $target->getArticleID() ] ) + ); + } + + protected function createPageSetWithRedirect() { + $target = Title::makeTitle( NS_MAIN, 'UTRedirectTarget' ); + $sourceA = Title::makeTitle( NS_MAIN, 'UTRedirectSourceA' ); + $sourceB = Title::makeTitle( NS_MAIN, 'UTRedirectSourceB' ); + self::editPage( 'UTRedirectTarget', 'api page set test' ); + self::editPage( 'UTRedirectSourceA', '#REDIRECT [[UTRedirectTarget]]' ); + self::editPage( 'UTRedirectSourceB', '#REDIRECT [[UTRedirectTarget]]' ); + + $request = new FauxRequest( [ 'redirects' => 1 ] ); + $context = new RequestContext(); + $context->setRequest( $request ); + $main = new ApiMain( $context ); + $pageSet = new ApiPageSet( $main ); + + $pageSet->setGeneratorData( $sourceA, [ 'index' => 1 ] ); + $pageSet->setGeneratorData( $sourceB, [ 'index' => 3 ] ); + $pageSet->populateFromTitles( [ $sourceA, $sourceB ] ); + + return [ $target, $pageSet ]; + } + + public function testHandleNormalization() { + $context = new RequestContext(); + $context->setRequest( new FauxRequest( [ 'titles' => "a|B|a\xcc\x8a" ] ) ); + $main = new ApiMain( $context ); + $pageSet = new ApiPageSet( $main ); + $pageSet->execute(); + + $this->assertSame( + [ 0 => [ 'A' => -1, 'B' => -2, 'Å' => -3 ] ], + $pageSet->getAllTitlesByNamespace() + ); + $this->assertSame( + [ + [ 'fromencoded' => true, 'from' => 'a%CC%8A', 'to' => 'å' ], + [ 'fromencoded' => false, 'from' => 'a', 'to' => 'A' ], + [ 'fromencoded' => false, 'from' => 'å', 'to' => 'Å' ], + ], + $pageSet->getNormalizedTitlesAsResult() + ); + } + + public function testSpecialRedirects() { + $id1 = self::editPage( 'UTApiPageSet', 'UTApiPageSet in the default language' ) + ->value['revision']->getTitle()->getArticleID(); + $id2 = self::editPage( 'UTApiPageSet/de', 'UTApiPageSet in German' ) + ->value['revision']->getTitle()->getArticleID(); + + $user = $this->getTestUser()->getUser(); + $userName = $user->getName(); + $userDbkey = str_replace( ' ', '_', $userName ); + $request = new FauxRequest( [ + 'titles' => implode( '|', [ + 'Special:MyContributions', + 'Special:MyPage', + 'Special:MyTalk/subpage', + 'Special:MyLanguage/UTApiPageSet', + ] ), + ] ); + $context = new RequestContext(); + $context->setRequest( $request ); + $context->setUser( $user ); + + $main = new ApiMain( $context ); + $pageSet = new ApiPageSet( $main ); + $pageSet->execute(); + + $this->assertEquals( [ + ], $pageSet->getRedirectTitlesAsResult() ); + $this->assertEquals( [ + [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ], + [ 'ns' => -1, 'title' => 'Special:MyPage', 'special' => true ], + [ 'ns' => -1, 'title' => 'Special:MyTalk/subpage', 'special' => true ], + [ 'ns' => -1, 'title' => 'Special:MyLanguage/UTApiPageSet', 'special' => true ], + ], $pageSet->getInvalidTitlesAndRevisions() ); + $this->assertEquals( [ + ], $pageSet->getAllTitlesByNamespace() ); + + $request->setVal( 'redirects', 1 ); + $main = new ApiMain( $context ); + $pageSet = new ApiPageSet( $main ); + $pageSet->execute(); + + $this->assertEquals( [ + [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ], + [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ], + [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet' ], + ], $pageSet->getRedirectTitlesAsResult() ); + $this->assertEquals( [ + [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ], + [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ], + [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ], + ], $pageSet->getInvalidTitlesAndRevisions() ); + $this->assertEquals( [ + 0 => [ 'UTApiPageSet' => $id1 ], + 2 => [ $userDbkey => -2 ], + 3 => [ "$userDbkey/subpage" => -3 ], + ], $pageSet->getAllTitlesByNamespace() ); + + $context->setLanguage( 'de' ); + $main = new ApiMain( $context ); + $pageSet = new ApiPageSet( $main ); + $pageSet->execute(); + + $this->assertEquals( [ + [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ], + [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ], + [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet/de' ], + ], $pageSet->getRedirectTitlesAsResult() ); + $this->assertEquals( [ + [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ], + [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ], + [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ], + ], $pageSet->getInvalidTitlesAndRevisions() ); + $this->assertEquals( [ + 0 => [ 'UTApiPageSet/de' => $id2 ], + 2 => [ $userDbkey => -2 ], + 3 => [ "$userDbkey/subpage" => -3 ], + ], $pageSet->getAllTitlesByNamespace() ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiParseTest.php b/www/wiki/tests/phpunit/includes/api/ApiParseTest.php new file mode 100644 index 00000000..a04271f6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiParseTest.php @@ -0,0 +1,849 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiParse + */ +class ApiParseTest extends ApiTestCase { + + protected static $pageId; + protected static $revIds = []; + + public function addDBDataOnce() { + $title = Title::newFromText( __CLASS__ ); + + $status = $this->editPage( __CLASS__, 'Test for revdel' ); + self::$pageId = $status->value['revision']->getPage(); + self::$revIds['revdel'] = $status->value['revision']->getId(); + + $status = $this->editPage( __CLASS__, 'Test for suppressed' ); + self::$revIds['suppressed'] = $status->value['revision']->getId(); + + $status = $this->editPage( __CLASS__, 'Test for oldid' ); + self::$revIds['oldid'] = $status->value['revision']->getId(); + + $status = $this->editPage( __CLASS__, 'Test for latest' ); + self::$revIds['latest'] = $status->value['revision']->getId(); + + $this->revisionDelete( self::$revIds['revdel'] ); + $this->revisionDelete( + self::$revIds['suppressed'], + [ Revision::DELETED_TEXT => 1, Revision::DELETED_RESTRICTED => 1 ] + ); + + Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason + } + + /** + * Assert that the given result of calling $this->doApiRequest() with + * action=parse resulted in $html, accounting for the boilerplate that the + * parser adds around the parsed page. Also asserts that warnings match + * the provided $warning. + * + * @param string $html Expected HTML + * @param array $res Returned from doApiRequest() + * @param string|null $warnings Exact value of expected warnings, null for + * no warnings + */ + protected function assertParsedTo( $expected, array $res, $warnings = null ) { + $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertSame' ] ); + } + + /** + * Same as above, but asserts that the HTML matches a regexp instead of a + * literal string match. + * + * @param string $html Expected HTML + * @param array $res Returned from doApiRequest() + * @param string|null $warnings Exact value of expected warnings, null for + * no warnings + */ + protected function assertParsedToRegExp( $expected, array $res, $warnings = null ) { + $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertRegExp' ] ); + } + + private function doAssertParsedTo( $expected, array $res, $warnings, callable $callback ) { + $html = $res[0]['parse']['text']; + + $expectedStart = '<div class="mw-parser-output">'; + $this->assertSame( $expectedStart, substr( $html, 0, strlen( $expectedStart ) ) ); + + $html = substr( $html, strlen( $expectedStart ) ); + + if ( $res[1]->getBool( 'disablelimitreport' ) ) { + $expectedEnd = "</div>"; + $this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) ); + + $html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) ); + } else { + $expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' . + '<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' . + '</div>(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?$#s'; + $this->assertRegExp( $expectedEnd, $html ); + + $html = preg_replace( $expectedEnd, '', $html ); + } + + call_user_func( $callback, $expected, $html ); + + if ( $warnings === null ) { + $this->assertCount( 1, $res[0] ); + } else { + $this->assertCount( 2, $res[0] ); + // This deliberately fails if there are extra warnings + $this->assertSame( [ 'parse' => [ 'warnings' => $warnings ] ], $res[0]['warnings'] ); + } + } + + /** + * Set up an interwiki entry for testing. + */ + protected function setupInterwiki() { + $dbw = wfGetDB( DB_MASTER ); + $dbw->insert( + 'interwiki', + [ + 'iw_prefix' => 'madeuplanguage', + 'iw_url' => "https://example.com/wiki/$1", + 'iw_api' => '', + 'iw_wikiid' => '', + 'iw_local' => false, + ], + __METHOD__, + 'IGNORE' + ); + + $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] ); + $this->tablesUsed[] = 'interwiki'; + } + + /** + * Set up a skin for testing. + * + * @todo Should this code be in MediaWikiTestCase or something? + */ + protected function setupSkin() { + $factory = new SkinFactory(); + $factory->register( 'testing', 'Testing', function () { + $skin = $this->getMockBuilder( SkinFallback::class ) + ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] ) + ->getMock(); + $skin->expects( $this->once() )->method( 'getDefaultModules' ) + ->willReturn( [ + 'core' => [ 'foo', 'bar' ], + 'content' => [ 'baz' ] + ] ); + $skin->expects( $this->once() )->method( 'setupSkinUserCss' ) + ->will( $this->returnCallback( function ( OutputPage $out ) { + $out->addModuleStyles( 'foo.styles' ); + } ) ); + return $skin; + } ); + $this->setService( 'SkinFactory', $factory ); + } + + public function testParseByName() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => __CLASS__, + ] ); + $this->assertParsedTo( "<p>Test for latest\n</p>", $res ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => __CLASS__, + 'disablelimitreport' => 1, + ] ); + $this->assertParsedTo( "<p>Test for latest\n</p>", $res ); + } + + public function testParseById() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pageid' => self::$pageId, + ] ); + $this->assertParsedTo( "<p>Test for latest\n</p>", $res ); + } + + public function testParseByOldId() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => self::$revIds['oldid'], + ] ); + $this->assertParsedTo( "<p>Test for oldid\n</p>", $res ); + $this->assertArrayNotHasKey( 'textdeleted', $res[0]['parse'] ); + $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] ); + } + + public function testRevDel() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => self::$revIds['revdel'], + ] ); + + $this->assertParsedTo( "<p>Test for revdel\n</p>", $res ); + $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] ); + $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] ); + } + + public function testRevDelNoPermission() { + $this->setExpectedException( ApiUsageException::class, + "You don't have permission to view deleted revision text." ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => self::$revIds['revdel'], + ], null, null, static::getTestUser()->getUser() ); + } + + public function testSuppressed() { + $this->setGroupPermissions( 'sysop', 'viewsuppressed', true ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => self::$revIds['suppressed'] + ] ); + + $this->assertParsedTo( "<p>Test for suppressed\n</p>", $res ); + $this->assertArrayHasKey( 'textsuppressed', $res[0]['parse'] ); + $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] ); + } + + public function testNonexistentPage() { + try { + $this->doApiRequest( [ + 'action' => 'parse', + 'page' => 'DoesNotExist', + ] ); + + $this->fail( "API did not return an error when parsing a nonexistent page" ); + } catch ( ApiUsageException $ex ) { + $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'missingtitle' ), + "Parse request for nonexistent page must give 'missingtitle' error: " + . var_export( self::getErrorFormatter()->arrayFromStatus( $ex->getStatusValue() ), true ) + ); + } + } + + public function testTitleProvided() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => 'Some interesting page', + 'text' => '{{PAGENAME}} has attracted my attention', + ] ); + + $this->assertParsedTo( "<p>Some interesting page has attracted my attention\n</p>", $res ); + } + + public function testSection() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, + "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'section' => 1, + ] ); + + $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content 1\n</p>!', $res ); + } + + public function testInvalidSection() { + $this->setExpectedException( ApiUsageException::class, + 'The "section" parameter must be a valid section ID or "new".' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'section' => 'T-new', + ] ); + } + + public function testSectionNoContent() { + $name = ucfirst( __FUNCTION__ ); + + $status = $this->editPage( $name, + "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" ); + + $this->setExpectedException( ApiUsageException::class, + "Missing content for page ID {$status->value['revision']->getPage()}." ); + + $this->db->delete( 'revision', [ 'rev_id' => $status->value['revision']->getId() ] ); + + // Suppress warning in WikiPage::getContentModel + Wikimedia\suppressWarnings(); + try { + $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'section' => 1, + ] ); + } finally { + Wikimedia\restoreWarnings(); + } + } + + public function testNewSectionWithPage() { + $this->setExpectedException( ApiUsageException::class, + '"section=new" cannot be combined with the "oldid", "pageid" or "page" ' . + 'parameters. Please use "title" and "text".' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'page' => __CLASS__, + 'section' => 'new', + ] ); + } + + public function testNonexistentOldId() { + $this->setExpectedException( ApiUsageException::class, + 'There is no revision with ID 2147483647.' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'oldid' => pow( 2, 31 ) - 1, + ] ); + } + + public function testUnfollowedRedirect() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "#REDIRECT [[$name 2]]" ); + $this->editPage( "$name 2", "Some ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + ] ); + + // Can't use assertParsedTo because the parser output is different for + // redirects + $this->assertRegExp( "/Redirect to:.*$name 2/", $res[0]['parse']['text'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testFollowedRedirect() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "#REDIRECT [[$name 2]]" ); + $this->editPage( "$name 2", "Some ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'redirects' => true, + ] ); + + $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res ); + } + + public function testFollowedRedirectById() { + $name = ucfirst( __FUNCTION__ ); + + $id = $this->editPage( $name, "#REDIRECT [[$name 2]]" )->value['revision']->getPage(); + $this->editPage( "$name 2", "Some ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pageid' => $id, + 'redirects' => true, + ] ); + + $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res ); + } + + public function testInvalidTitle() { + $this->setExpectedException( ApiUsageException::class, 'Bad title "|".' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'title' => '|', + ] ); + } + + public function testTitleWithNonexistentRevId() { + $this->setExpectedException( ApiUsageException::class, + 'There is no revision with ID 2147483647.' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'revid' => pow( 2, 31 ) - 1, + ] ); + } + + public function testTitleWithNonMatchingRevId() { + $name = ucfirst( __FUNCTION__ ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => $name, + 'revid' => self::$revIds['latest'], + 'text' => 'Some text', + ] ); + + $this->assertParsedTo( "<p>Some text\n</p>", $res, + 'r' . self::$revIds['latest'] . " is not a revision of $name." ); + } + + public function testRevId() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'revid' => self::$revIds['latest'], + 'text' => 'My revid is {{REVISIONID}}!', + ] ); + + $this->assertParsedTo( "<p>My revid is " . self::$revIds['latest'] . "!\n</p>", $res ); + } + + public function testTitleNoText() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => 'Special:AllPages', + ] ); + + $this->assertParsedTo( '', $res, + '"title" used without "text", and parsed page properties were requested. ' . + 'Did you mean to use "page" instead of "title"?' ); + } + + public function testRevidNoText() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'revid' => self::$revIds['latest'], + ] ); + + $this->assertParsedTo( '', $res, + '"revid" used without "text", and parsed page properties were requested. ' . + 'Did you mean to use "oldid" instead of "revid"?' ); + } + + public function testTextNoContentModel() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Some ''text''", + ] ); + + $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res, + 'No "title" or "contentmodel" was given, assuming wikitext.' ); + } + + public function testSerializationError() { + $this->setExpectedException( APIUsageException::class, + 'Content serialization failed: Could not unserialize content' ); + + $this->mergeMwGlobalArrayValue( 'wgContentHandlers', + [ 'testing-serialize-error' => 'DummySerializeErrorContentHandler' ] ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Some ''text''", + 'contentmodel' => 'testing-serialize-error', + ] ); + } + + public function testNewSection() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'section' => 'new', + 'sectiontitle' => 'Title', + 'text' => 'Content', + ] ); + + $this->assertParsedToRegExp( '!<h2>.*Title.*</h2>\n<p>Content\n</p>!', $res ); + } + + public function testExistingSection() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'section' => 1, + 'text' => "Intro\n\n== Section 1 ==\n\nContent\n\n== Section 2 ==\n\nMore content", + ] ); + + $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content\n</p>!', $res ); + } + + public function testNoPst() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Template:$name", "Template ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "{{subst:$name}}", + 'contentmodel' => 'wikitext', + ] ); + + $this->assertParsedTo( "<p>{{subst:$name}}\n</p>", $res ); + } + + public function testPst() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Template:$name", "Template ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pst' => '', + 'text' => "{{subst:$name}}", + 'contentmodel' => 'wikitext', + 'prop' => 'text|wikitext', + ] ); + + $this->assertParsedTo( "<p>Template <i>text</i>\n</p>", $res ); + $this->assertSame( "{{subst:$name}}", $res[0]['parse']['wikitext'] ); + } + + public function testOnlyPst() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Template:$name", "Template ''text''" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'onlypst' => '', + 'text' => "{{subst:$name}}", + 'contentmodel' => 'wikitext', + 'prop' => 'text|wikitext', + 'summary' => 'Summary', + ] ); + + $this->assertSame( + [ 'parse' => [ + 'text' => "Template ''text''", + 'wikitext' => "{{subst:$name}}", + 'parsedsummary' => 'Summary', + ] ], + $res[0] + ); + } + + public function testHeadHtml() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => __CLASS__, + 'prop' => 'headhtml', + ] ); + + // Just do a rough sanity check + $this->assertRegExp( '#<!DOCTYPE.*<html.*<head.*</head>.*<body#s', + $res[0]['parse']['headhtml'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testCategoriesHtml() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( $name, "[[Category:$name]]" ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'page' => $name, + 'prop' => 'categorieshtml', + ] ); + + $this->assertRegExp( "#Category.*Category:$name.*$name#", + $res[0]['parse']['categorieshtml'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testEffectiveLangLinks() { + $hookRan = false; + $this->setTemporaryHook( 'LanguageLinks', + function () use ( &$hookRan ) { + $hookRan = true; + } + ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => '[[zh:' . __CLASS__ . ']]', + 'effectivelanglinks' => '', + ] ); + + $this->assertTrue( $hookRan ); + $this->assertSame( 'The parameter "effectivelanglinks" has been deprecated.', + $res[0]['warnings']['parse']['warnings'] ); + } + + /** + * @param array $arr Extra params to add to API request + */ + private function doTestLangLinks( array $arr = [] ) { + $this->setupInterwiki(); + + $res = $this->doApiRequest( array_merge( [ + 'action' => 'parse', + 'title' => 'Omelette', + 'text' => '[[madeuplanguage:Omelette]]', + 'prop' => 'langlinks', + ], $arr ) ); + + $langLinks = $res[0]['parse']['langlinks']; + + $this->assertCount( 1, $langLinks ); + $this->assertSame( 'madeuplanguage', $langLinks[0]['lang'] ); + $this->assertSame( 'Omelette', $langLinks[0]['title'] ); + $this->assertSame( 'https://example.com/wiki/Omelette', $langLinks[0]['url'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testLangLinks() { + $this->doTestLangLinks(); + } + + public function testLangLinksWithSkin() { + $this->setupSkin(); + $this->doTestLangLinks( [ 'useskin' => 'testing' ] ); + } + + public function testHeadItems() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => '', + 'prop' => 'headitems', + ] ); + + $this->assertSame( [], $res[0]['parse']['headitems'] ); + $this->assertSame( + '"prop=headitems" is deprecated since MediaWiki 1.28. ' . + 'Use "prop=headhtml" when creating new HTML documents, ' . + 'or "prop=modules|jsconfigvars" when updating a document client-side.', + $res[0]['warnings']['parse']['warnings'] + ); + } + + public function testHeadItemsWithSkin() { + $this->setupSkin(); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => '', + 'prop' => 'headitems', + 'useskin' => 'testing', + ] ); + + $this->assertSame( [], $res[0]['parse']['headitems'] ); + $this->assertSame( + '"prop=headitems" is deprecated since MediaWiki 1.28. ' . + 'Use "prop=headhtml" when creating new HTML documents, ' . + 'or "prop=modules|jsconfigvars" when updating a document client-side.', + $res[0]['warnings']['parse']['warnings'] + ); + } + + public function testModules() { + $this->setTemporaryHook( 'ParserAfterParse', + function ( $parser ) { + $output = $parser->getOutput(); + $output->addModules( [ 'foo', 'bar' ] ); + $output->addModuleScripts( [ 'baz', 'quuz' ] ); + $output->addModuleStyles( [ 'aaa', 'zzz' ] ); + $output->addJsConfigVars( [ 'x' => 'y', 'z' => -3 ] ); + } + ); + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => 'Content', + 'prop' => 'modules|jsconfigvars|encodedjsconfigvars', + ] ); + + $this->assertSame( [ 'foo', 'bar' ], $res[0]['parse']['modules'] ); + $this->assertSame( [ 'baz', 'quuz' ], $res[0]['parse']['modulescripts'] ); + $this->assertSame( [ 'aaa', 'zzz' ], $res[0]['parse']['modulestyles'] ); + $this->assertSame( [ 'x' => 'y', 'z' => -3 ], $res[0]['parse']['jsconfigvars'] ); + $this->assertSame( '{"x":"y","z":-3}', $res[0]['parse']['encodedjsconfigvars'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testModulesWithSkin() { + $this->setupSkin(); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pageid' => self::$pageId, + 'useskin' => 'testing', + 'prop' => 'modules', + ] ); + $this->assertSame( + [ 'foo', 'bar', 'baz' ], + $res[0]['parse']['modules'], + 'resp.parse.modules' + ); + $this->assertSame( + [], + $res[0]['parse']['modulescripts'], + 'resp.parse.modulescripts' + ); + $this->assertSame( + [ 'foo.styles' ], + $res[0]['parse']['modulestyles'], + 'resp.parse.modulestyles' + ); + $this->assertSame( + [ 'parse' => + [ 'warnings' => + 'Property "modules" was set but not "jsconfigvars" or ' . + '"encodedjsconfigvars". Configuration variables are necessary for ' . + 'proper module usage.' + ] + ], + $res[0]['warnings'] + ); + } + + public function testIndicators() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => + '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>', + 'prop' => 'indicators', + ] ); + + $this->assertSame( + // It seems we return in markup order and not display order + [ 'b' => 'BBB!', 'a' => 'aaa' ], + $res[0]['parse']['indicators'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testIndicatorsWithSkin() { + $this->setupSkin(); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => + '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>', + 'prop' => 'indicators', + 'useskin' => 'testing', + ] ); + + $this->assertSame( + // Now we return in display order rather than markup order + [ 'a' => 'aaa', 'b' => 'BBB!' ], + $res[0]['parse']['indicators'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testIwlinks() { + $this->setupInterwiki(); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => 'Omelette', + 'text' => '[[:madeuplanguage:Omelette]][[madeuplanguage:Spaghetti]]', + 'prop' => 'iwlinks', + ] ); + + $iwlinks = $res[0]['parse']['iwlinks']; + + $this->assertCount( 1, $iwlinks ); + $this->assertSame( 'madeuplanguage', $iwlinks[0]['prefix'] ); + $this->assertSame( 'https://example.com/wiki/Omelette', $iwlinks[0]['url'] ); + $this->assertSame( 'madeuplanguage:Omelette', $iwlinks[0]['title'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testLimitReports() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'pageid' => self::$pageId, + 'prop' => 'limitreportdata|limitreporthtml', + ] ); + + // We don't bother testing the actual values here + $this->assertInternalType( 'array', $res[0]['parse']['limitreportdata'] ); + $this->assertInternalType( 'string', $res[0]['parse']['limitreporthtml'] ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testParseTreeNonWikitext() { + $this->setExpectedException( ApiUsageException::class, + '"prop=parsetree" is only supported for wikitext content.' ); + + $this->doApiRequest( [ + 'action' => 'parse', + 'text' => '', + 'contentmodel' => 'json', + 'prop' => 'parsetree', + ] ); + } + + public function testParseTree() { + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "Some ''text'' is {{nice|to have|i=think}}", + 'contentmodel' => 'wikitext', + 'prop' => 'parsetree', + ] ); + + // Preprocessor_DOM and Preprocessor_Hash give different results here, + // so we'll accept either + $this->assertRegExp( + '#^<root>Some \'\'text\'\' is <template><title>nice</title>' . + '<part><name index="1"/><value>to have</value></part>' . + '<part><name>i</name>(?:<equals>)?=(?:</equals>)?<value>think</value></part>' . + '</template></root>$#', + $res[0]['parse']['parsetree'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + public function testDisableTidy() { + $this->setMwGlobals( 'wgTidyConfig', [ 'driver' => 'RemexHtml' ] ); + + // Check that disabletidy doesn't have an effect just because tidying + // doesn't work for some other reason + $res1 = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "<b>Mixed <i>up</b></i>", + 'contentmodel' => 'wikitext', + ] ); + $this->assertParsedTo( "<p><b>Mixed <i>up</i></b>\n</p>", $res1 ); + + $res2 = $this->doApiRequest( [ + 'action' => 'parse', + 'text' => "<b>Mixed <i>up</b></i>", + 'contentmodel' => 'wikitext', + 'disabletidy' => '', + ] ); + + $this->assertParsedTo( "<p><b>Mixed <i>up</b></i>\n</p>", $res2 ); + } + + public function testFormatCategories() { + $name = ucfirst( __FUNCTION__ ); + + $this->editPage( "Category:$name", 'Content' ); + $this->editPage( 'Category:Hidden', '__HIDDENCAT__' ); + + $res = $this->doApiRequest( [ + 'action' => 'parse', + 'title' => __CLASS__, + 'text' => "[[Category:$name]][[Category:Foo|Sort me]][[Category:Hidden]]", + 'prop' => 'categories', + ] ); + + $this->assertSame( + [ [ 'sortkey' => '', 'category' => $name ], + [ 'sortkey' => 'Sort me', 'category' => 'Foo', 'missing' => true ], + [ 'sortkey' => '', 'category' => 'Hidden', 'hidden' => true ] ], + $res[0]['parse']['categories'] + ); + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php b/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php new file mode 100644 index 00000000..96d9a384 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php @@ -0,0 +1,40 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiPurge + */ +class ApiPurgeTest extends ApiTestCase { + + /** + * @group Broken + */ + public function testPurgeMainPage() { + if ( !Title::newFromText( 'UTPage' )->exists() ) { + $this->markTestIncomplete( "The article [[UTPage]] does not exist" ); + } + + $somePage = mt_rand(); + + $data = $this->doApiRequest( [ + 'action' => 'purge', + 'titles' => 'UTPage|' . $somePage . '|%5D' ] ); + + $this->assertArrayHasKey( 'purge', $data[0], + "Must receive a 'purge' result from API" ); + + $this->assertEquals( + 3, + count( $data[0]['purge'] ), + "Purge request for three articles should give back three results received: " + . var_export( $data[0]['purge'], true ) ); + + $pages = [ 'UTPage' => 'purged', $somePage => 'missing', '%5D' => 'invalid' ]; + foreach ( $data[0]['purge'] as $v ) { + $this->assertArrayHasKey( $pages[$v['title']], $v ); + } + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php new file mode 100644 index 00000000..2d89aa54 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php @@ -0,0 +1,36 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiQueryAllPages + */ +class ApiQueryAllPagesTest extends ApiTestCase { + /** + * Test T27702 + * Prefixes of API search requests are not handled with case sensitivity and may result + * in wrong search results + */ + public function testPrefixNormalizationSearchBug() { + $title = Title::newFromText( 'Category:Template:xyz' ); + $page = WikiPage::factory( $title ); + + $page->doEditContent( + ContentHandler::makeContent( 'Some text', $page->getTitle() ), + 'inserting content' + ); + + $result = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'allpages', + 'apnamespace' => NS_CATEGORY, + 'apprefix' => 'Template:x' ] ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'allpages', $result[0]['query'] ); + $this->assertNotEquals( 0, count( $result[0]['query']['allpages'] ), + 'allpages list does not contain page Category:Template:xyz' ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php new file mode 100644 index 00000000..5b43dd1b --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php @@ -0,0 +1,976 @@ +<?php + +use MediaWiki\Linker\LinkTarget; +use MediaWiki\MediaWikiServices; + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiQueryRecentChanges + */ +class ApiQueryRecentChangesIntegrationTest extends ApiTestCase { + + public function __construct( $name = null, array $data = [], $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + + $this->tablesUsed[] = 'recentchanges'; + $this->tablesUsed[] = 'page'; + } + + protected function setUp() { + parent::setUp(); + + self::$users['ApiQueryRecentChangesIntegrationTestUser'] = $this->getMutableTestUser(); + wfGetDB( DB_MASTER )->delete( 'recentchanges', '*', __METHOD__ ); + } + + private function getLoggedInTestUser() { + return self::$users['ApiQueryRecentChangesIntegrationTestUser']->getUser(); + } + + private function doPageEdit( User $user, LinkTarget $target, $summary ) { + static $i = 0; + + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( __CLASS__ . $i++, $title ), + $summary, + 0, + false, + $user + ); + } + + private function doMinorPageEdit( User $user, LinkTarget $target, $summary ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( __CLASS__, $title ), + $summary, + EDIT_MINOR, + false, + $user + ); + } + + private function doBotPageEdit( User $user, LinkTarget $target, $summary ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( __CLASS__, $title ), + $summary, + EDIT_FORCE_BOT, + false, + $user + ); + } + + private function doAnonPageEdit( LinkTarget $target, $summary ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( __CLASS__, $title ), + $summary, + 0, + false, + User::newFromId( 0 ) + ); + } + + private function deletePage( LinkTarget $target, $reason ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doDeleteArticleReal( $reason ); + } + + /** + * Performs a batch of page edits as a specified user + * @param User $user + * @param array $editData associative array, keys: + * - target => LinkTarget page to edit + * - summary => string edit summary + * - minorEdit => bool mark as minor edit if true (defaults to false) + * - botEdit => bool mark as bot edit if true (defaults to false) + */ + private function doPageEdits( User $user, array $editData ) { + foreach ( $editData as $singleEditData ) { + if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) { + $this->doMinorPageEdit( + $user, + $singleEditData['target'], + $singleEditData['summary'] + ); + continue; + } + if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) { + $this->doBotPageEdit( + $user, + $singleEditData['target'], + $singleEditData['summary'] + ); + continue; + } + $this->doPageEdit( + $user, + $singleEditData['target'], + $singleEditData['summary'] + ); + } + } + + private function doListRecentChangesRequest( array $params = [] ) { + return $this->doApiRequest( + array_merge( + [ 'action' => 'query', 'list' => 'recentchanges' ], + $params + ), + null, + false, + $this->getLoggedInTestUser() + ); + } + + private function doGeneratorRecentChangesRequest( array $params = [] ) { + return $this->doApiRequest( + array_merge( + [ 'action' => 'query', 'generator' => 'recentchanges' ], + $params + ), + null, + false, + $this->getLoggedInTestUser() + ); + } + + private function getItemsFromApiResponse( array $response ) { + return $response[0]['query']['recentchanges']; + } + + private function getTitleFormatter() { + return new MediaWikiTitleCodec( + Language::factory( 'en' ), + MediaWikiServices::getInstance()->getGenderCache() + ); + } + + private function getPrefixedText( LinkTarget $target ) { + $formatter = $this->getTitleFormatter(); + return $formatter->getPrefixedText( $target ); + } + + public function testListRecentChanges_returnsRCInfo() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' ); + + $result = $this->doListRecentChangesRequest(); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'recentchanges', $result[0]['query'] ); + + $items = $this->getItemsFromApiResponse( $result ); + $this->assertCount( 1, $items ); + $item = $items[0]; + $this->assertArraySubset( + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + $item + ); + $this->assertArrayNotHasKey( 'bot', $item ); + $this->assertArrayNotHasKey( 'new', $item ); + $this->assertArrayNotHasKey( 'minor', $item ); + $this->assertArrayHasKey( 'pageid', $item ); + $this->assertArrayHasKey( 'revid', $item ); + $this->assertArrayHasKey( 'old_revid', $item ); + } + + public function testIdsPropParameter() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'ids', ] ); + $items = $this->getItemsFromApiResponse( $result ); + + $this->assertCount( 1, $items ); + $this->assertArrayHasKey( 'pageid', $items[0] ); + $this->assertArrayHasKey( 'revid', $items[0] ); + $this->assertArrayHasKey( 'old_revid', $items[0] ); + $this->assertEquals( 'new', $items[0]['type'] ); + } + + public function testTitlePropParameter() { + $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $subjectTarget, + 'summary' => 'Create the page', + ], + [ + 'target' => $talkTarget, + 'summary' => 'Create Talk page', + ], + ] + ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ), + ], + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testFlagsPropParameter() { + $normalEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $minorEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageM' ); + $botEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageB' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $normalEditTarget, + 'summary' => 'Create the page', + ], + [ + 'target' => $minorEditTarget, + 'summary' => 'Create the page', + ], + [ + 'target' => $minorEditTarget, + 'summary' => 'Change content', + 'minorEdit' => true, + ], + [ + 'target' => $botEditTarget, + 'summary' => 'Create the page with a bot', + 'botEdit' => true, + ], + ] + ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'flags', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'new' => true, + 'minor' => false, + 'bot' => true, + ], + [ + 'type' => 'edit', + 'new' => false, + 'minor' => true, + 'bot' => false, + ], + [ + 'type' => 'new', + 'new' => true, + 'minor' => false, + 'bot' => false, + ], + [ + 'type' => 'new', + 'new' => true, + 'minor' => false, + 'bot' => false, + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testUserPropParameter() { + $userEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $anonEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageA' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $userEditTarget, 'Create the page' ); + $this->doAnonPageEdit( $anonEditTarget, 'Create the page' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'user', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'anon' => true, + 'user' => User::newFromId( 0 )->getName(), + ], + [ + 'type' => 'new', + 'user' => $this->getLoggedInTestUser()->getName(), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testUserIdPropParameter() { + $user = $this->getLoggedInTestUser(); + $userEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $anonEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageA' ); + $this->doPageEdit( $user, $userEditTarget, 'Create the page' ); + $this->doAnonPageEdit( $anonEditTarget, 'Create the page' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'userid', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'anon' => true, + 'userid' => 0, + ], + [ + 'type' => 'new', + 'userid' => $user->getId(), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testCommentPropParameter() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'comment', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'comment' => 'Create the <b>page</b>', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testParsedCommentPropParameter() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'parsedcomment', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'parsedcomment' => 'Create the <b>page</b>', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testTimestampPropParameter() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'timestamp', ] ); + $items = $this->getItemsFromApiResponse( $result ); + + $this->assertCount( 1, $items ); + $this->assertArrayHasKey( 'timestamp', $items[0] ); + $this->assertInternalType( 'string', $items[0]['timestamp'] ); + } + + public function testSizesPropParameter() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'sizes', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'oldlen' => 0, + 'newlen' => 38, + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + private function createPageAndDeleteIt( LinkTarget $target ) { + $this->doPageEdit( $this->getLoggedInTestUser(), + $target, + 'Create the page that will be deleted' + ); + $this->deletePage( $target, 'Important Reason' ); + } + + public function testLoginfoPropParameter() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->createPageAndDeleteIt( $target ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'loginfo', ] ); + + $items = $this->getItemsFromApiResponse( $result ); + $this->assertCount( 1, $items ); + $this->assertArraySubset( + [ + 'type' => 'log', + 'logtype' => 'delete', + 'logaction' => 'delete', + 'logparams' => [], + ], + $items[0] + ); + $this->assertArrayHasKey( 'logid', $items[0] ); + } + + public function testEmptyPropParameter() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $user, $target, 'Create the page' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => '', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + ] + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testNamespaceParam() { + $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $subjectTarget, + 'summary' => 'Create the page', + ], + [ + 'target' => $talkTarget, + 'summary' => 'Create the talk page', + ], + ] + ); + + $result = $this->doListRecentChangesRequest( [ 'rcnamespace' => '0', ] ); + + $items = $this->getItemsFromApiResponse( $result ); + $this->assertCount( 1, $items ); + $this->assertArraySubset( + [ + 'ns' => 0, + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + $items[0] + ); + } + + public function testShowAnonParams() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doAnonPageEdit( $target, 'Create the page' ); + + $resultAnon = $this->doListRecentChangesRequest( [ + 'rcprop' => 'user', + 'rcshow' => WatchedItemQueryService::FILTER_ANON + ] ); + $resultNotAnon = $this->doListRecentChangesRequest( [ + 'rcprop' => 'user', + 'rcshow' => WatchedItemQueryService::FILTER_NOT_ANON + ] ); + + $items = $this->getItemsFromApiResponse( $resultAnon ); + $this->assertCount( 1, $items ); + $this->assertArraySubset( [ 'anon' => true ], $items[0] ); + $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotAnon ) ); + } + + public function testNewAndEditTypeParameters() { + $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $subjectTarget, + 'summary' => 'Create the page', + ], + [ + 'target' => $subjectTarget, + 'summary' => 'Change the content', + ], + [ + 'target' => $talkTarget, + 'summary' => 'Create Talk page', + ], + ] + ); + + $resultNew = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'new' ] ); + $resultEdit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'edit' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ), + ], + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $resultNew ) + ); + $this->assertEquals( + [ + [ + 'type' => 'edit', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $resultEdit ) + ); + } + + public function testLogTypeParameters() { + $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->createPageAndDeleteIt( $subjectTarget ); + $this->doPageEdit( $this->getLoggedInTestUser(), $talkTarget, 'Create Talk page' ); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'log' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'log', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + private function getExternalRC( LinkTarget $target ) { + $title = Title::newFromLinkTarget( $target ); + + $rc = new RecentChange; + $rc->mTitle = $title; + $rc->mAttribs = [ + 'rc_timestamp' => wfTimestamp( TS_MW ), + 'rc_namespace' => $title->getNamespace(), + 'rc_title' => $title->getDBkey(), + 'rc_type' => RC_EXTERNAL, + 'rc_source' => 'foo', + 'rc_minor' => 0, + 'rc_cur_id' => $title->getArticleID(), + 'rc_user' => 0, + 'rc_user_text' => 'm>External User', + 'rc_comment' => '', + 'rc_comment_text' => '', + 'rc_comment_data' => null, + 'rc_this_oldid' => $title->getLatestRevID(), + 'rc_last_oldid' => $title->getLatestRevID(), + 'rc_bot' => 0, + 'rc_ip' => '', + 'rc_patrolled' => 0, + 'rc_new' => 0, + 'rc_old_len' => $title->getLength(), + 'rc_new_len' => $title->getLength(), + 'rc_deleted' => 0, + 'rc_logid' => 0, + 'rc_log_type' => null, + 'rc_log_action' => '', + 'rc_params' => '', + ]; + $rc->mExtra = [ + 'prefixedDBkey' => $title->getPrefixedDBkey(), + 'lastTimestamp' => 0, + 'oldSize' => $title->getLength(), + 'newSize' => $title->getLength(), + 'pageStatus' => 'changed' + ]; + + return $rc; + } + + public function testExternalTypeParameters() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $user, $subjectTarget, 'Create the page' ); + $this->doPageEdit( $user, $talkTarget, 'Create Talk page' ); + + $rc = $this->getExternalRC( $subjectTarget ); + $rc->save(); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'external' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'external', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testCategorizeTypeParameter() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryRecentChangesIntegrationTestCategory' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $categoryTarget, + 'summary' => 'Create the category', + ], + [ + 'target' => $subjectTarget, + 'summary' => 'Create the page and add it to the category', + ], + ] + ); + $title = Title::newFromLinkTarget( $subjectTarget ); + $revision = Revision::newFromTitle( $title ); + + $rc = RecentChange::newForCategorization( + $revision->getTimestamp(), + Title::newFromLinkTarget( $categoryTarget ), + $user, + $revision->getComment(), + $title, + 0, + $revision->getId(), + null, + false + ); + $rc->save(); + + $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'categorize' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'categorize', + 'ns' => $categoryTarget->getNamespace(), + 'title' => $this->getPrefixedText( $categoryTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testLimitParam() { + $target1 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $target2 = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $target3 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage2' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $target1, + 'summary' => 'Create the page', + ], + [ + 'target' => $target2, + 'summary' => 'Create Talk page', + ], + [ + 'target' => $target3, + 'summary' => 'Create the page', + ], + ] + ); + + $resultWithoutLimit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] ); + $resultWithLimit = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target3->getNamespace(), + 'title' => $this->getPrefixedText( $target3 ) + ], + [ + 'type' => 'new', + 'ns' => $target2->getNamespace(), + 'title' => $this->getPrefixedText( $target2 ) + ], + [ + 'type' => 'new', + 'ns' => $target1->getNamespace(), + 'title' => $this->getPrefixedText( $target1 ) + ], + ], + $this->getItemsFromApiResponse( $resultWithoutLimit ) + ); + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target3->getNamespace(), + 'title' => $this->getPrefixedText( $target3 ) + ], + [ + 'type' => 'new', + 'ns' => $target2->getNamespace(), + 'title' => $this->getPrefixedText( $target2 ) + ], + ], + $this->getItemsFromApiResponse( $resultWithLimit ) + ); + $this->assertArrayHasKey( 'continue', $resultWithLimit[0] ); + $this->assertArrayHasKey( 'rccontinue', $resultWithLimit[0]['continue'] ); + } + + public function testAllRevParam() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $target, + 'summary' => 'Create the page', + ], + [ + 'target' => $target, + 'summary' => 'Change the content', + ], + ] + ); + + $resultAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rcallrev' => '', ] ); + $resultNoAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'edit', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + ], + $this->getItemsFromApiResponse( $resultNoAllRev ) + ); + $this->assertEquals( + [ + [ + 'type' => 'edit', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + ], + $this->getItemsFromApiResponse( $resultAllRev ) + ); + } + + public function testDirParams() { + $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $subjectTarget, + 'summary' => 'Create the page', + ], + [ + 'target' => $talkTarget, + 'summary' => 'Create Talk page', + ], + ] + ); + + $resultDirOlder = $this->doListRecentChangesRequest( + [ 'rcdir' => 'older', 'rcprop' => 'title' ] + ); + $resultDirNewer = $this->doListRecentChangesRequest( + [ 'rcdir' => 'newer', 'rcprop' => 'title' ] + ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ) + ], + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ) + ], + ], + $this->getItemsFromApiResponse( $resultDirOlder ) + ); + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ) + ], + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ) + ], + ], + $this->getItemsFromApiResponse( $resultDirNewer ) + ); + } + + public function testStartEndParams() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' ); + + $resultStart = $this->doListRecentChangesRequest( [ + 'rcstart' => '20010115000000', + 'rcdir' => 'newer', + 'rcprop' => 'title', + ] ); + $resultEnd = $this->doListRecentChangesRequest( [ + 'rcend' => '20010115000000', + 'rcdir' => 'newer', + 'rcprop' => 'title', + ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ] + ], + $this->getItemsFromApiResponse( $resultStart ) + ); + $this->assertEmpty( $this->getItemsFromApiResponse( $resultEnd ) ); + } + + public function testContinueParam() { + $target1 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $target2 = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' ); + $target3 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage2' ); + $this->doPageEdits( + $this->getLoggedInTestUser(), + [ + [ + 'target' => $target1, + 'summary' => 'Create the page', + ], + [ + 'target' => $target2, + 'summary' => 'Create Talk page', + ], + [ + 'target' => $target3, + 'summary' => 'Create the page', + ], + ] + ); + + $firstResult = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] ); + $this->assertArrayHasKey( 'continue', $firstResult[0] ); + $this->assertArrayHasKey( 'rccontinue', $firstResult[0]['continue'] ); + + $continuationParam = $firstResult[0]['continue']['rccontinue']; + + $continuedResult = $this->doListRecentChangesRequest( + [ 'rccontinue' => $continuationParam, 'rcprop' => 'title' ] + ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target3->getNamespace(), + 'title' => $this->getPrefixedText( $target3 ), + ], + [ + 'type' => 'new', + 'ns' => $target2->getNamespace(), + 'title' => $this->getPrefixedText( $target2 ), + ], + ], + $this->getItemsFromApiResponse( $firstResult ) + ); + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target1->getNamespace(), + 'title' => $this->getPrefixedText( $target1 ) + ] + ], + $this->getItemsFromApiResponse( $continuedResult ) + ); + } + + public function testGeneratorRecentChangesPropInfo_returnsRCPages() { + $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' ); + $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' ); + + $result = $this->doGeneratorRecentChangesRequest( [ 'prop' => 'info' ] ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'pages', $result[0]['query'] ); + + // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them + $pages = array_values( $result[0]['query']['pages'] ); + + $this->assertCount( 1, $pages ); + $this->assertArraySubset( + [ + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + 'new' => true, + ], + $pages[0] + ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php new file mode 100644 index 00000000..5f59d6fb --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php @@ -0,0 +1,1608 @@ +<?php + +use MediaWiki\Linker\LinkTarget; +use MediaWiki\MediaWikiServices; + +/** + * @group medium + * @group API + * @group Database + * + * @covers ApiQueryWatchlist + */ +class ApiQueryWatchlistIntegrationTest extends ApiTestCase { + + public function __construct( $name = null, array $data = [], $dataName = '' ) { + parent::__construct( $name, $data, $dataName ); + $this->tablesUsed = array_unique( + array_merge( $this->tablesUsed, [ 'watchlist', 'recentchanges', 'page' ] ) + ); + } + + protected function setUp() { + parent::setUp(); + self::$users['ApiQueryWatchlistIntegrationTestUser'] = $this->getMutableTestUser(); + self::$users['ApiQueryWatchlistIntegrationTestUser2'] = $this->getMutableTestUser(); + } + + private function getLoggedInTestUser() { + return self::$users['ApiQueryWatchlistIntegrationTestUser']->getUser(); + } + + private function getNonLoggedInTestUser() { + return self::$users['ApiQueryWatchlistIntegrationTestUser2']->getUser(); + } + + private function doPageEdit( User $user, LinkTarget $target, $content, $summary ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( $content, $title ), + $summary, + 0, + false, + $user + ); + } + + private function doMinorPageEdit( User $user, LinkTarget $target, $content, $summary ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( $content, $title ), + $summary, + EDIT_MINOR, + false, + $user + ); + } + + private function doBotPageEdit( User $user, LinkTarget $target, $content, $summary ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( $content, $title ), + $summary, + EDIT_FORCE_BOT, + false, + $user + ); + } + + private function doAnonPageEdit( LinkTarget $target, $content, $summary ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doEditContent( + ContentHandler::makeContent( $content, $title ), + $summary, + 0, + false, + User::newFromId( 0 ) + ); + } + + private function doPatrolledPageEdit( + User $user, + LinkTarget $target, + $content, + $summary, + User $patrollingUser + ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $status = $page->doEditContent( + ContentHandler::makeContent( $content, $title ), + $summary, + 0, + false, + $user + ); + /** @var Revision $rev */ + $rev = $status->value['revision']; + $rc = $rev->getRecentChange(); + $rc->doMarkPatrolled( $patrollingUser, false, [] ); + } + + private function deletePage( LinkTarget $target, $reason ) { + $title = Title::newFromLinkTarget( $target ); + $page = WikiPage::factory( $title ); + $page->doDeleteArticleReal( $reason ); + } + + /** + * Performs a batch of page edits as a specified user + * @param User $user + * @param array $editData associative array, keys: + * - target => LinkTarget page to edit + * - content => string new content + * - summary => string edit summary + * - minorEdit => bool mark as minor edit if true (defaults to false) + * - botEdit => bool mark as bot edit if true (defaults to false) + */ + private function doPageEdits( User $user, array $editData ) { + foreach ( $editData as $singleEditData ) { + if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) { + $this->doMinorPageEdit( + $user, + $singleEditData['target'], + $singleEditData['content'], + $singleEditData['summary'] + ); + continue; + } + if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) { + $this->doBotPageEdit( + $user, + $singleEditData['target'], + $singleEditData['content'], + $singleEditData['summary'] + ); + continue; + } + $this->doPageEdit( + $user, + $singleEditData['target'], + $singleEditData['content'], + $singleEditData['summary'] + ); + } + } + + private function getWatchedItemStore() { + return MediaWikiServices::getInstance()->getWatchedItemStore(); + } + + /** + * @param User $user + * @param LinkTarget[] $targets + */ + private function watchPages( User $user, array $targets ) { + $store = $this->getWatchedItemStore(); + $store->addWatchBatchForUser( $user, $targets ); + } + + private function doListWatchlistRequest( array $params = [], $user = null ) { + if ( $user === null ) { + $user = $this->getLoggedInTestUser(); + } + return $this->doApiRequest( + array_merge( + [ 'action' => 'query', 'list' => 'watchlist' ], + $params + ), null, false, $user + ); + } + + private function doGeneratorWatchlistRequest( array $params = [] ) { + return $this->doApiRequest( + array_merge( + [ 'action' => 'query', 'generator' => 'watchlist' ], + $params + ), null, false, $this->getLoggedInTestUser() + ); + } + + private function getItemsFromApiResponse( array $response ) { + return $response[0]['query']['watchlist']; + } + + /** + * Convenience method to assert that actual items array fetched from API is equal to the expected + * array, Unlike assertEquals this only checks if values of specified keys are equal in both + * arrays. This could be used e.g. not to compare IDs that could change between test run + * but only stable keys. + * Optionally this also checks that specified keys are present in the actual item without + * performing any checks on the related values. + * + * @param array $actualItems array of actual items (associative arrays) + * @param array $expectedItems array of expected items (associative arrays), + * those items have less keys than actual items + * @param array $keysUsedInValueComparison list of keys of the actual item that will be used + * in the comparison of values + * @param array $requiredKeys optional, list of keys that must be present in the + * actual items. Values of those keys are not checked. + */ + private function assertArraySubsetsEqual( + array $actualItems, + array $expectedItems, + array $keysUsedInValueComparison, + array $requiredKeys = [] + ) { + $this->assertCount( count( $expectedItems ), $actualItems ); + + // not checking values of all keys of the actual item, so removing unwanted keys from comparison + $actualItemsOnlyComparedValues = array_map( + function ( array $item ) use ( $keysUsedInValueComparison ) { + return array_intersect_key( $item, array_flip( $keysUsedInValueComparison ) ); + }, + $actualItems + ); + + $this->assertEquals( + $expectedItems, + $actualItemsOnlyComparedValues + ); + + // Check that each item in $actualItems contains all of keys specified in $requiredKeys + $actualItemsKeysOnly = array_map( 'array_keys', $actualItems ); + foreach ( $actualItemsKeysOnly as $keysOfTheItem ) { + $this->assertEmpty( array_diff( $requiredKeys, $keysOfTheItem ) ); + } + } + + private function getTitleFormatter() { + return new MediaWikiTitleCodec( + Language::factory( 'en' ), + MediaWikiServices::getInstance()->getGenderCache() + ); + } + + private function getPrefixedText( LinkTarget $target ) { + $formatter = $this->getTitleFormatter(); + return $formatter->getPrefixedText( $target ); + } + + private function cleanTestUsersWatchlist() { + $user = $this->getLoggedInTestUser(); + $store = $this->getWatchedItemStore(); + $items = $store->getWatchedItemsForUser( $user ); + foreach ( $items as $item ) { + $store->removeWatch( $user, $item->getLinkTarget() ); + } + } + + public function testListWatchlist_returnsWatchedItemsWithRCInfo() { + // Clean up after previous tests that might have added something to the watchlist of + // the user with the same user ID as user used here as the test user + $this->cleanTestUsersWatchlist(); + + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doListWatchlistRequest(); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'watchlist', $result[0]['query'] ); + + $this->assertArraySubsetsEqual( + $this->getItemsFromApiResponse( $result ), + [ + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + 'bot' => false, + 'new' => true, + 'minor' => false, + ] + ], + [ 'type', 'ns', 'title', 'bot', 'new', 'minor' ], + [ 'pageid', 'revid', 'old_revid' ] + ); + } + + public function testIdsPropParameter() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'ids', ] ); + $items = $this->getItemsFromApiResponse( $result ); + + $this->assertCount( 1, $items ); + $this->assertArrayHasKey( 'pageid', $items[0] ); + $this->assertArrayHasKey( 'revid', $items[0] ); + $this->assertArrayHasKey( 'old_revid', $items[0] ); + $this->assertEquals( 'new', $items[0]['type'] ); + } + + public function testTitlePropParameter() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $subjectTarget, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $talkTarget, + 'content' => 'Some Talk Page Content', + 'summary' => 'Create Talk page', + ], + ] + ); + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ), + ], + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testFlagsPropParameter() { + $user = $this->getLoggedInTestUser(); + $normalEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $minorEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageM' ); + $botEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageB' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $normalEditTarget, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $minorEditTarget, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $minorEditTarget, + 'content' => 'Slightly Better Content', + 'summary' => 'Change content', + 'minorEdit' => true, + ], + [ + 'target' => $botEditTarget, + 'content' => 'Some Content', + 'summary' => 'Create the page with a bot', + 'botEdit' => true, + ], + ] + ); + $this->watchPages( $user, [ $normalEditTarget, $minorEditTarget, $botEditTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'flags', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'new' => true, + 'minor' => false, + 'bot' => true, + ], + [ + 'type' => 'edit', + 'new' => false, + 'minor' => true, + 'bot' => false, + ], + [ + 'type' => 'new', + 'new' => true, + 'minor' => false, + 'bot' => false, + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testUserPropParameter() { + $user = $this->getLoggedInTestUser(); + $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' ); + $this->doPageEdit( + $user, + $userEditTarget, + 'Some Content', + 'Create the page' + ); + $this->doAnonPageEdit( + $anonEditTarget, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'user', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'anon' => true, + 'user' => User::newFromId( 0 )->getName(), + ], + [ + 'type' => 'new', + 'user' => $user->getName(), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testUserIdPropParameter() { + $user = $this->getLoggedInTestUser(); + $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' ); + $this->doPageEdit( + $user, + $userEditTarget, + 'Some Content', + 'Create the page' + ); + $this->doAnonPageEdit( + $anonEditTarget, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'userid', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'anon' => true, + 'user' => 0, + 'userid' => 0, + ], + [ + 'type' => 'new', + 'user' => $user->getId(), + 'userid' => $user->getId(), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testCommentPropParameter() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the <b>page</b>' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'comment', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'comment' => 'Create the <b>page</b>', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testParsedCommentPropParameter() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the <b>page</b>' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'parsedcomment', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'parsedcomment' => 'Create the <b>page</b>', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testTimestampPropParameter() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'timestamp', ] ); + $items = $this->getItemsFromApiResponse( $result ); + + $this->assertCount( 1, $items ); + $this->assertArrayHasKey( 'timestamp', $items[0] ); + $this->assertInternalType( 'string', $items[0]['timestamp'] ); + } + + public function testSizesPropParameter() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'sizes', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'oldlen' => 0, + 'newlen' => 12, + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testNotificationTimestampPropParameter() { + $otherUser = $this->getNonLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $otherUser, + $target, + 'Some Content', + 'Create the page' + ); + $store = $this->getWatchedItemStore(); + $store->addWatch( $this->getLoggedInTestUser(), $target ); + $store->updateNotificationTimestamp( + $otherUser, + $target, + '20151212010101' + ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'notificationtimestamp' => '2015-12-12T01:01:01Z', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + private function setupPatrolledSpecificFixtures( User $user ) { + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + + $this->doPatrolledPageEdit( + $user, + $target, + 'Some Content', + 'Create the page (this gets patrolled)', + $user + ); + + $this->watchPages( $user, [ $target ] ); + } + + public function testPatrolPropParameter() { + $testUser = static::getTestSysop(); + $user = $testUser->getUser(); + $this->setupPatrolledSpecificFixtures( $user ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', ], $user ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'patrolled' => true, + 'unpatrolled' => false, + 'autopatrolled' => false, + ] + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + private function createPageAndDeleteIt( LinkTarget $target ) { + $this->doPageEdit( + $this->getLoggedInTestUser(), + $target, + 'Some Content', + 'Create the page that will be deleted' + ); + $this->deletePage( $target, 'Important Reason' ); + } + + public function testLoginfoPropParameter() { + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->createPageAndDeleteIt( $target ); + + $this->watchPages( $this->getLoggedInTestUser(), [ $target ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'loginfo', ] ); + + $this->assertArraySubsetsEqual( + $this->getItemsFromApiResponse( $result ), + [ + [ + 'type' => 'log', + 'logtype' => 'delete', + 'logaction' => 'delete', + 'logparams' => [], + ], + ], + [ 'type', 'logtype', 'logaction', 'logparams' ], + [ 'logid' ] + ); + } + + public function testEmptyPropParameter() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => '', ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + ] + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testNamespaceParam() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $subjectTarget, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $talkTarget, + 'content' => 'Some Content', + 'summary' => 'Create the talk page', + ], + ] + ); + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlnamespace' => '0', ] ); + + $this->assertArraySubsetsEqual( + $this->getItemsFromApiResponse( $result ), + [ + [ + 'ns' => 0, + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + [ 'ns', 'title' ] + ); + } + + public function testUserParam() { + $user = $this->getLoggedInTestUser(); + $otherUser = $this->getNonLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $subjectTarget, + 'Some Content', + 'Create the page' + ); + $this->doPageEdit( + $otherUser, + $talkTarget, + 'What is this page about?', + 'Create the talk page' + ); + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $result = $this->doListWatchlistRequest( [ + 'wlprop' => 'user|title', + 'wluser' => $otherUser->getName(), + ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ), + 'user' => $otherUser->getName(), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testExcludeUserParam() { + $user = $this->getLoggedInTestUser(); + $otherUser = $this->getNonLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $subjectTarget, + 'Some Content', + 'Create the page' + ); + $this->doPageEdit( + $otherUser, + $talkTarget, + 'What is this page about?', + 'Create the talk page' + ); + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $result = $this->doListWatchlistRequest( [ + 'wlprop' => 'user|title', + 'wlexcludeuser' => $otherUser->getName(), + ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + 'user' => $user->getName(), + ] + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testShowMinorParams() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $target, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $target, + 'content' => 'Slightly Better Content', + 'summary' => 'Change content', + 'minorEdit' => true, + ], + ] + ); + $this->watchPages( $user, [ $target ] ); + + $resultMinor = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_MINOR, + 'wlprop' => 'flags' + ] ); + $resultNotMinor = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_NOT_MINOR, 'wlprop' => 'flags' + ] ); + + $this->assertArraySubsetsEqual( + $this->getItemsFromApiResponse( $resultMinor ), + [ + [ 'minor' => true, ] + ], + [ 'minor' ] + ); + $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotMinor ) ); + } + + public function testShowBotParams() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doBotPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $resultBot = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_BOT + ] ); + $resultNotBot = $this->doListWatchlistRequest( [ + 'wlshow' => WatchedItemQueryService::FILTER_NOT_BOT + ] ); + + $this->assertArraySubsetsEqual( + $this->getItemsFromApiResponse( $resultBot ), + [ + [ 'bot' => true ], + ], + [ 'bot' ] + ); + $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotBot ) ); + } + + public function testShowAnonParams() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doAnonPageEdit( + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $resultAnon = $this->doListWatchlistRequest( [ + 'wlprop' => 'user', + 'wlshow' => WatchedItemQueryService::FILTER_ANON + ] ); + $resultNotAnon = $this->doListWatchlistRequest( [ + 'wlprop' => 'user', + 'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON + ] ); + + $this->assertArraySubsetsEqual( + $this->getItemsFromApiResponse( $resultAnon ), + [ + [ 'anon' => true ], + ], + [ 'anon' ] + ); + $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotAnon ) ); + } + + public function testShowUnreadParams() { + $user = $this->getLoggedInTestUser(); + $otherUser = $this->getNonLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $subjectTarget, + 'Some Content', + 'Create the page' + ); + $this->doPageEdit( + $otherUser, + $talkTarget, + 'Some Content', + 'Create the talk page' + ); + $store = $this->getWatchedItemStore(); + $store->addWatchBatchForUser( $user, [ $subjectTarget, $talkTarget ] ); + $store->updateNotificationTimestamp( + $otherUser, + $talkTarget, + '20151212010101' + ); + + $resultUnread = $this->doListWatchlistRequest( [ + 'wlprop' => 'notificationtimestamp|title', + 'wlshow' => WatchedItemQueryService::FILTER_UNREAD + ] ); + $resultNotUnread = $this->doListWatchlistRequest( [ + 'wlprop' => 'notificationtimestamp|title', + 'wlshow' => WatchedItemQueryService::FILTER_NOT_UNREAD + ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'notificationtimestamp' => '2015-12-12T01:01:01Z', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ) + ] + ], + $this->getItemsFromApiResponse( $resultUnread ) + ); + $this->assertEquals( + [ + [ + 'type' => 'new', + 'notificationtimestamp' => '', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ) + ] + ], + $this->getItemsFromApiResponse( $resultNotUnread ) + ); + } + + public function testShowPatrolledParams() { + $user = static::getTestSysop()->getUser(); + $this->setupPatrolledSpecificFixtures( $user ); + + $resultPatrolled = $this->doListWatchlistRequest( [ + 'wlprop' => 'patrol', + 'wlshow' => WatchedItemQueryService::FILTER_PATROLLED + ], $user ); + $resultNotPatrolled = $this->doListWatchlistRequest( [ + 'wlprop' => 'patrol', + 'wlshow' => WatchedItemQueryService::FILTER_NOT_PATROLLED + ], $user ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'patrolled' => true, + 'unpatrolled' => false, + 'autopatrolled' => false, + ] + ], + $this->getItemsFromApiResponse( $resultPatrolled ) + ); + $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotPatrolled ) ); + } + + public function testNewAndEditTypeParameters() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $subjectTarget, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $subjectTarget, + 'content' => 'Some Other Content', + 'summary' => 'Change the content', + ], + [ + 'target' => $talkTarget, + 'content' => 'Some Talk Page Content', + 'summary' => 'Create Talk page', + ], + ] + ); + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $resultNew = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'new' ] ); + $resultEdit = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'edit' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ), + ], + ], + $this->getItemsFromApiResponse( $resultNew ) + ); + $this->assertEquals( + [ + [ + 'type' => 'edit', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $resultEdit ) + ); + } + + public function testLogTypeParameters() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->createPageAndDeleteIt( $subjectTarget ); + $this->doPageEdit( + $user, + $talkTarget, + 'Some Talk Page Content', + 'Create Talk page' + ); + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'log' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'log', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + private function getExternalRC( LinkTarget $target ) { + $title = Title::newFromLinkTarget( $target ); + + $rc = new RecentChange; + $rc->mTitle = $title; + $rc->mAttribs = [ + 'rc_timestamp' => wfTimestamp( TS_MW ), + 'rc_namespace' => $title->getNamespace(), + 'rc_title' => $title->getDBkey(), + 'rc_type' => RC_EXTERNAL, + 'rc_source' => 'foo', + 'rc_minor' => 0, + 'rc_cur_id' => $title->getArticleID(), + 'rc_user' => 0, + 'rc_user_text' => 'ext>External User', + 'rc_comment' => '', + 'rc_comment_text' => '', + 'rc_comment_data' => null, + 'rc_this_oldid' => $title->getLatestRevID(), + 'rc_last_oldid' => $title->getLatestRevID(), + 'rc_bot' => 0, + 'rc_ip' => '', + 'rc_patrolled' => 0, + 'rc_new' => 0, + 'rc_old_len' => $title->getLength(), + 'rc_new_len' => $title->getLength(), + 'rc_deleted' => 0, + 'rc_logid' => 0, + 'rc_log_type' => null, + 'rc_log_action' => '', + 'rc_params' => '', + ]; + $rc->mExtra = [ + 'prefixedDBkey' => $title->getPrefixedDBkey(), + 'lastTimestamp' => 0, + 'oldSize' => $title->getLength(), + 'newSize' => $title->getLength(), + 'pageStatus' => 'changed' + ]; + + return $rc; + } + + public function testExternalTypeParameters() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $subjectTarget, + 'Some Content', + 'Create the page' + ); + $this->doPageEdit( + $user, + $talkTarget, + 'Some Talk Page Content', + 'Create Talk page' + ); + + $rc = $this->getExternalRC( $subjectTarget ); + $rc->save(); + + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'external' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'external', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testCategorizeTypeParameter() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryWatchlistIntegrationTestCategory' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $categoryTarget, + 'content' => 'Some Content', + 'summary' => 'Create the category', + ], + [ + 'target' => $subjectTarget, + 'content' => 'Some Content [[Category:ApiQueryWatchlistIntegrationTestCategory]]t', + 'summary' => 'Create the page and add it to the category', + ], + ] + ); + $title = Title::newFromLinkTarget( $subjectTarget ); + $revision = Revision::newFromTitle( $title ); + + $rc = RecentChange::newForCategorization( + $revision->getTimestamp(), + Title::newFromLinkTarget( $categoryTarget ), + $user, + $revision->getComment(), + $title, + 0, + $revision->getId(), + null, + false + ); + $rc->save(); + + $this->watchPages( $user, [ $subjectTarget, $categoryTarget ] ); + + $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'categorize' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'categorize', + 'ns' => $categoryTarget->getNamespace(), + 'title' => $this->getPrefixedText( $categoryTarget ), + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testLimitParam() { + $user = $this->getLoggedInTestUser(); + $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $target1, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $target2, + 'content' => 'Some Talk Page Content', + 'summary' => 'Create Talk page', + ], + [ + 'target' => $target3, + 'content' => 'Some Other Content', + 'summary' => 'Create the page', + ], + ] + ); + $this->watchPages( $user, [ $target1, $target2, $target3 ] ); + + $resultWithoutLimit = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] ); + $resultWithLimit = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target3->getNamespace(), + 'title' => $this->getPrefixedText( $target3 ) + ], + [ + 'type' => 'new', + 'ns' => $target2->getNamespace(), + 'title' => $this->getPrefixedText( $target2 ) + ], + [ + 'type' => 'new', + 'ns' => $target1->getNamespace(), + 'title' => $this->getPrefixedText( $target1 ) + ], + ], + $this->getItemsFromApiResponse( $resultWithoutLimit ) + ); + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target3->getNamespace(), + 'title' => $this->getPrefixedText( $target3 ) + ], + [ + 'type' => 'new', + 'ns' => $target2->getNamespace(), + 'title' => $this->getPrefixedText( $target2 ) + ], + ], + $this->getItemsFromApiResponse( $resultWithLimit ) + ); + $this->assertArrayHasKey( 'continue', $resultWithLimit[0] ); + $this->assertArrayHasKey( 'wlcontinue', $resultWithLimit[0]['continue'] ); + } + + public function testAllRevParam() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $target, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $target, + 'content' => 'Some Other Content', + 'summary' => 'Change the content', + ], + ] + ); + $this->watchPages( $user, [ $target ] ); + + $resultAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wlallrev' => '', ] ); + $resultNoAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'edit', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + ], + $this->getItemsFromApiResponse( $resultNoAllRev ) + ); + $this->assertEquals( + [ + [ + 'type' => 'edit', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ], + ], + $this->getItemsFromApiResponse( $resultAllRev ) + ); + } + + public function testDirParams() { + $user = $this->getLoggedInTestUser(); + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $subjectTarget, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $talkTarget, + 'content' => 'Some Talk Page Content', + 'summary' => 'Create Talk page', + ], + ] + ); + $this->watchPages( $user, [ $subjectTarget, $talkTarget ] ); + + $resultDirOlder = $this->doListWatchlistRequest( [ 'wldir' => 'older', 'wlprop' => 'title' ] ); + $resultDirNewer = $this->doListWatchlistRequest( [ 'wldir' => 'newer', 'wlprop' => 'title' ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ) + ], + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ) + ], + ], + $this->getItemsFromApiResponse( $resultDirOlder ) + ); + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $subjectTarget->getNamespace(), + 'title' => $this->getPrefixedText( $subjectTarget ) + ], + [ + 'type' => 'new', + 'ns' => $talkTarget->getNamespace(), + 'title' => $this->getPrefixedText( $talkTarget ) + ], + ], + $this->getItemsFromApiResponse( $resultDirNewer ) + ); + } + + public function testStartEndParams() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $resultStart = $this->doListWatchlistRequest( [ + 'wlstart' => '20010115000000', + 'wldir' => 'newer', + 'wlprop' => 'title', + ] ); + $resultEnd = $this->doListWatchlistRequest( [ + 'wlend' => '20010115000000', + 'wldir' => 'newer', + 'wlprop' => 'title', + ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + ] + ], + $this->getItemsFromApiResponse( $resultStart ) + ); + $this->assertEmpty( $this->getItemsFromApiResponse( $resultEnd ) ); + } + + public function testContinueParam() { + $user = $this->getLoggedInTestUser(); + $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' ); + $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $target1, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $target2, + 'content' => 'Some Talk Page Content', + 'summary' => 'Create Talk page', + ], + [ + 'target' => $target3, + 'content' => 'Some Other Content', + 'summary' => 'Create the page', + ], + ] + ); + $this->watchPages( $user, [ $target1, $target2, $target3 ] ); + + $firstResult = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] ); + $this->assertArrayHasKey( 'continue', $firstResult[0] ); + $this->assertArrayHasKey( 'wlcontinue', $firstResult[0]['continue'] ); + + $continuationParam = $firstResult[0]['continue']['wlcontinue']; + + $continuedResult = $this->doListWatchlistRequest( + [ 'wlcontinue' => $continuationParam, 'wlprop' => 'title' ] + ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target3->getNamespace(), + 'title' => $this->getPrefixedText( $target3 ), + ], + [ + 'type' => 'new', + 'ns' => $target2->getNamespace(), + 'title' => $this->getPrefixedText( $target2 ), + ], + ], + $this->getItemsFromApiResponse( $firstResult ) + ); + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target1->getNamespace(), + 'title' => $this->getPrefixedText( $target1 ) + ] + ], + $this->getItemsFromApiResponse( $continuedResult ) + ); + } + + public function testOwnerAndTokenParams() { + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $this->getLoggedInTestUser(), + $target, + 'Some Content', + 'Create the page' + ); + + $otherUser = $this->getNonLoggedInTestUser(); + $otherUser->setOption( 'watchlisttoken', '1234567890' ); + $otherUser->saveSettings(); + + $this->watchPages( $otherUser, [ $target ] ); + + $reloadedUser = User::newFromName( $otherUser->getName() ); + $this->assertEquals( '1234567890', $reloadedUser->getOption( 'watchlisttoken' ) ); + + $result = $this->doListWatchlistRequest( [ + 'wlowner' => $otherUser->getName(), + 'wltoken' => '1234567890', + 'wlprop' => 'title', + ] ); + + $this->assertEquals( + [ + [ + 'type' => 'new', + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ) + ] + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testOwnerAndTokenParams_wrongToken() { + $otherUser = $this->getNonLoggedInTestUser(); + $otherUser->setOption( 'watchlisttoken', '1234567890' ); + $otherUser->saveSettings(); + + $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' ); + + $this->doListWatchlistRequest( [ + 'wlowner' => $otherUser->getName(), + 'wltoken' => 'wrong-token', + ] ); + } + + public function testOwnerAndTokenParams_noWatchlistTokenSet() { + $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' ); + + $this->doListWatchlistRequest( [ + 'wlowner' => $this->getNonLoggedInTestUser()->getName(), + 'wltoken' => 'some-token', + ] ); + } + + public function testGeneratorWatchlistPropInfo_returnsWatchedPages() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdit( + $user, + $target, + 'Some Content', + 'Create the page' + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'info' ] ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'pages', $result[0]['query'] ); + + // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them + $pages = array_values( $result[0]['query']['pages'] ); + + $this->assertArraySubsetsEqual( + $pages, + [ + [ + 'ns' => $target->getNamespace(), + 'title' => $this->getPrefixedText( $target ), + 'new' => true, + ] + ], + [ 'ns', 'title', 'new' ] + ); + } + + public function testGeneratorWatchlistPropRevisions_returnsWatchedItemsRevisions() { + $user = $this->getLoggedInTestUser(); + $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' ); + $this->doPageEdits( + $user, + [ + [ + 'target' => $target, + 'content' => 'Some Content', + 'summary' => 'Create the page', + ], + [ + 'target' => $target, + 'content' => 'Some Other Content', + 'summary' => 'Change the content', + ], + ] + ); + $this->watchPages( $user, [ $target ] ); + + $result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'revisions', 'gwlallrev' => '' ] ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'pages', $result[0]['query'] ); + + // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them + $pages = array_values( $result[0]['query']['pages'] ); + + $this->assertCount( 1, $pages ); + $this->assertEquals( 0, $pages[0]['ns'] ); + $this->assertEquals( $this->getPrefixedText( $target ), $pages[0]['title'] ); + $this->assertArraySubsetsEqual( + $pages[0]['revisions'], + [ + [ + 'comment' => 'Create the page', + 'user' => $user->getName(), + 'minor' => false, + ], + [ + 'comment' => 'Change the content', + 'user' => $user->getName(), + 'minor' => false, + ], + ], + [ 'comment', 'user', 'minor' ] + ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php new file mode 100644 index 00000000..2af63c49 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php @@ -0,0 +1,542 @@ +<?php + +use MediaWiki\MediaWikiServices; + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiQueryWatchlistRaw + */ +class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase { + + protected function setUp() { + parent::setUp(); + self::$users['ApiQueryWatchlistRawIntegrationTestUser'] + = $this->getMutableTestUser(); + self::$users['ApiQueryWatchlistRawIntegrationTestUser2'] + = $this->getMutableTestUser(); + } + + private function getLoggedInTestUser() { + return self::$users['ApiQueryWatchlistRawIntegrationTestUser']->getUser(); + } + + private function getNotLoggedInTestUser() { + return self::$users['ApiQueryWatchlistRawIntegrationTestUser2']->getUser(); + } + + private function getWatchedItemStore() { + return MediaWikiServices::getInstance()->getWatchedItemStore(); + } + + private function doListWatchlistRawRequest( array $params = [] ) { + return $this->doApiRequest( array_merge( + [ 'action' => 'query', 'list' => 'watchlistraw' ], + $params + ), null, false, $this->getLoggedInTestUser() ); + } + + private function doGeneratorWatchlistRawRequest( array $params = [] ) { + return $this->doApiRequest( array_merge( + [ 'action' => 'query', 'generator' => 'watchlistraw' ], + $params + ), null, false, $this->getLoggedInTestUser() ); + } + + private function getItemsFromApiResponse( array $response ) { + return $response[0]['watchlistraw']; + } + + public function testListWatchlistRaw_returnsWatchedItems() { + $store = $this->getWatchedItemStore(); + $store->addWatch( + $this->getLoggedInTestUser(), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ) + ); + + $result = $this->doListWatchlistRawRequest(); + + $this->assertArrayHasKey( 'watchlistraw', $result[0] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testPropChanged_addsNotificationTimestamp() { + $target = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ); + $otherUser = $this->getNotLoggedInTestUser(); + + $store = $this->getWatchedItemStore(); + + $store->addWatch( $this->getLoggedInTestUser(), $target ); + $store->updateNotificationTimestamp( + $otherUser, + $target, + '20151212010101' + ); + + $result = $this->doListWatchlistRawRequest( [ 'wrprop' => 'changed' ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + 'changed' => '2015-12-12T01:01:01Z', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testNamespaceParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' ), + ] ); + + $result = $this->doListWatchlistRawRequest( [ 'wrnamespace' => '0' ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testShowChangedParams() { + $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ); + $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' ); + $otherUser = $this->getNotLoggedInTestUser(); + + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + $subjectTarget, + $talkTarget, + ] ); + $store->updateNotificationTimestamp( + $otherUser, + $subjectTarget, + '20151212010101' + ); + + $resultChanged = $this->doListWatchlistRawRequest( + [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_CHANGED ] + ); + $resultNotChanged = $this->doListWatchlistRawRequest( + [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_NOT_CHANGED ] + ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage', + 'changed' => '2015-12-12T01:01:01Z', + ], + ], + $this->getItemsFromApiResponse( $resultChanged ) + ); + + $this->assertEquals( + [ + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage', + ], + ], + $this->getItemsFromApiResponse( $resultNotChanged ) + ); + } + + public function testLimitParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $resultWithoutLimit = $this->doListWatchlistRawRequest(); + $resultWithLimit = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $resultWithoutLimit ) + ); + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + ], + $this->getItemsFromApiResponse( $resultWithLimit ) + ); + + $this->assertArrayNotHasKey( 'continue', $resultWithoutLimit[0] ); + $this->assertArrayHasKey( 'continue', $resultWithLimit[0] ); + $this->assertArrayHasKey( 'wrcontinue', $resultWithLimit[0]['continue'] ); + } + + public function testDirParams() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $resultDirAsc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] ); + $resultDirDesc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'descending' ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $resultDirAsc ) + ); + + $this->assertEquals( + [ + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $resultDirDesc ) + ); + } + + public function testAscendingIsDefaultOrder() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $resultNoDir = $this->doListWatchlistRawRequest(); + $resultAscDir = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] ); + + $this->assertEquals( + $this->getItemsFromApiResponse( $resultNoDir ), + $this->getItemsFromApiResponse( $resultAscDir ) + ); + } + + public function testFromTitleParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $result = $this->doListWatchlistRawRequest( [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testToTitleParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $result = $this->doListWatchlistRawRequest( [ + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testContinueParam() { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $firstResult = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] ); + $continuationParam = $firstResult[0]['continue']['wrcontinue']; + + $this->assertEquals( '0|ApiQueryWatchlistRawIntegrationTestPage3', $continuationParam ); + + $continuedResult = $this->doListWatchlistRawRequest( [ 'wrcontinue' => $continuationParam ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3', + ] + ], + $this->getItemsFromApiResponse( $continuedResult ) + ); + } + + public function fromTitleToTitleContinueComboProvider() { + return [ + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1' ], + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ], + ], + ], + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ], + ], + ], + [ + [ + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ], + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ], + ], + ], + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3', + ], + [ + [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ], + ], + ], + ]; + } + + /** + * @dataProvider fromTitleToTitleContinueComboProvider + */ + public function testFromTitleToTitleContinueCombo( array $params, array $expectedItems ) { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ), + ] ); + + $result = $this->doListWatchlistRawRequest( $params ); + + $this->assertEquals( $expectedItems, $this->getItemsFromApiResponse( $result ) ); + } + + public function fromTitleToTitleContinueSelfContradictoryComboProvider() { + return [ + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ] + ], + [ + [ + 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2', + 'wrdir' => 'descending', + ] + ], + [ + [ + 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1', + 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2', + ] + ], + ]; + } + + /** + * @dataProvider fromTitleToTitleContinueSelfContradictoryComboProvider + */ + public function testFromTitleToTitleContinueSelfContradictoryCombo( array $params ) { + $store = $this->getWatchedItemStore(); + + $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ), + ] ); + + $result = $this->doListWatchlistRawRequest( $params ); + + $this->assertEmpty( $this->getItemsFromApiResponse( $result ) ); + $this->assertArrayNotHasKey( 'continue', $result[0] ); + } + + public function testOwnerAndTokenParams() { + $otherUser = $this->getNotLoggedInTestUser(); + $otherUser->setOption( 'watchlisttoken', '1234567890' ); + $otherUser->saveSettings(); + + $store = $this->getWatchedItemStore(); + $store->addWatchBatchForUser( $otherUser, [ + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ), + ] ); + + ObjectCache::getMainWANInstance()->clearProcessCache(); + $result = $this->doListWatchlistRawRequest( [ + 'wrowner' => $otherUser->getName(), + 'wrtoken' => '1234567890', + ] ); + + $this->assertEquals( + [ + [ + 'ns' => 0, + 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1', + ], + [ + 'ns' => 1, + 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1', + ], + ], + $this->getItemsFromApiResponse( $result ) + ); + } + + public function testOwnerAndTokenParams_wrongToken() { + $otherUser = $this->getNotLoggedInTestUser(); + $otherUser->setOption( 'watchlisttoken', '1234567890' ); + $otherUser->saveSettings(); + + $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' ); + + $this->doListWatchlistRawRequest( [ + 'wrowner' => $otherUser->getName(), + 'wrtoken' => 'wrong-token', + ] ); + } + + public function testOwnerAndTokenParams_userHasNoWatchlistToken() { + $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' ); + + $this->doListWatchlistRawRequest( [ + 'wrowner' => $this->getNotLoggedInTestUser()->getName(), + 'wrtoken' => 'some-watchlist-token', + ] ); + } + + public function testGeneratorWatchlistRawPropInfo_returnsWatchedItems() { + $store = $this->getWatchedItemStore(); + $store->addWatch( + $this->getLoggedInTestUser(), + new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ) + ); + + $result = $this->doGeneratorWatchlistRawRequest( [ 'prop' => 'info' ] ); + + $this->assertArrayHasKey( 'query', $result[0] ); + $this->assertArrayHasKey( 'pages', $result[0]['query'] ); + $this->assertCount( 1, $result[0]['query']['pages'] ); + + // $result[0]['query']['pages'] uses page ids as keys + $item = array_values( $result[0]['query']['pages'] )[0]; + + $this->assertEquals( 0, $item['ns'] ); + $this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPage', $item['title'] ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiResultTest.php b/www/wiki/tests/phpunit/includes/api/ApiResultTest.php new file mode 100644 index 00000000..98e24fb6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiResultTest.php @@ -0,0 +1,1410 @@ +<?php + +/** + * @covers ApiResult + * @group API + */ +class ApiResultTest extends MediaWikiTestCase { + + /** + * @covers ApiResult + */ + public function testStaticDataMethods() { + $arr = []; + + ApiResult::setValue( $arr, 'setValue', '1' ); + + ApiResult::setValue( $arr, null, 'unnamed 1' ); + ApiResult::setValue( $arr, null, 'unnamed 2' ); + + ApiResult::setValue( $arr, 'deleteValue', '2' ); + ApiResult::unsetValue( $arr, 'deleteValue' ); + + ApiResult::setContentValue( $arr, 'setContentValue', '3' ); + + $this->assertSame( [ + 'setValue' => '1', + 'unnamed 1', + 'unnamed 2', + ApiResult::META_CONTENT => 'setContentValue', + 'setContentValue' => '3', + ], $arr ); + + try { + ApiResult::setValue( $arr, 'setValue', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to add element setValue=99, existing value is 1', + $ex->getMessage(), + 'Expected exception' + ); + } + + try { + ApiResult::setContentValue( $arr, 'setContentValue2', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to set content element as setContentValue2 when setContentValue ' . + 'is already set as the content element', + $ex->getMessage(), + 'Expected exception' + ); + } + + ApiResult::setValue( $arr, 'setValue', '99', ApiResult::OVERRIDE ); + $this->assertSame( '99', $arr['setValue'] ); + + ApiResult::setContentValue( $arr, 'setContentValue2', '99', ApiResult::OVERRIDE ); + $this->assertSame( 'setContentValue2', $arr[ApiResult::META_CONTENT] ); + + $arr = [ 'foo' => 1, 'bar' => 1 ]; + ApiResult::setValue( $arr, 'top', '2', ApiResult::ADD_ON_TOP ); + ApiResult::setValue( $arr, null, '2', ApiResult::ADD_ON_TOP ); + ApiResult::setValue( $arr, 'bottom', '2' ); + ApiResult::setValue( $arr, 'foo', '2', ApiResult::OVERRIDE ); + ApiResult::setValue( $arr, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP ); + $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom' ], array_keys( $arr ) ); + + $arr = []; + ApiResult::setValue( $arr, 'sub', [ 'foo' => 1 ] ); + ApiResult::setValue( $arr, 'sub', [ 'bar' => 1 ] ); + $this->assertSame( [ 'sub' => [ 'foo' => 1, 'bar' => 1 ] ], $arr ); + + try { + ApiResult::setValue( $arr, 'sub', [ 'foo' => 2, 'baz' => 2 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Conflicting keys (foo) when attempting to merge element sub', + $ex->getMessage(), + 'Expected exception' + ); + } + + $arr = []; + $title = Title::newFromText( "MediaWiki:Foobar" ); + $obj = new stdClass; + $obj->foo = 1; + $obj->bar = 2; + ApiResult::setValue( $arr, 'title', $title ); + ApiResult::setValue( $arr, 'obj', $obj ); + $this->assertSame( [ + 'title' => (string)$title, + 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ], + ], $arr ); + + $fh = tmpfile(); + try { + ApiResult::setValue( $arr, 'file', $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, null, $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + ApiResult::setValue( $arr, 'sub', $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + ApiResult::setValue( $arr, null, $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + fclose( $fh ); + + try { + ApiResult::setValue( $arr, 'inf', INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, null, INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, 'nan', NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + ApiResult::setValue( $arr, null, NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + ApiResult::setValue( $arr, null, NAN, ApiResult::NO_VALIDATE ); + + try { + ApiResult::setValue( $arr, null, NAN, ApiResult::NO_SIZE_CHECK ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $arr = []; + $result2 = new ApiResult( 8388608 ); + $result2->addValue( null, 'foo', 'bar' ); + ApiResult::setValue( $arr, 'baz', $result2 ); + $this->assertSame( [ + 'baz' => [ + ApiResult::META_TYPE => 'assoc', + 'foo' => 'bar', + ] + ], $arr ); + + $arr = []; + ApiResult::setValue( $arr, 'foo', "foo\x80bar" ); + ApiResult::setValue( $arr, 'bar', "a\xcc\x81" ); + ApiResult::setValue( $arr, 'baz', 74 ); + ApiResult::setValue( $arr, null, "foo\x80bar" ); + ApiResult::setValue( $arr, null, "a\xcc\x81" ); + $this->assertSame( [ + 'foo' => "foo\xef\xbf\xbdbar", + 'bar' => "\xc3\xa1", + 'baz' => 74, + 0 => "foo\xef\xbf\xbdbar", + 1 => "\xc3\xa1", + ], $arr ); + + $obj = new stdClass; + $obj->{'1'} = 'one'; + $arr = []; + ApiResult::setValue( $arr, 'foo', $obj ); + $this->assertSame( [ + 'foo' => [ + 1 => 'one', + ApiResult::META_TYPE => 'assoc', + ] + ], $arr ); + } + + /** + * @covers ApiResult + */ + public function testInstanceDataMethods() { + $result = new ApiResult( 8388608 ); + + $result->addValue( null, 'setValue', '1' ); + + $result->addValue( null, null, 'unnamed 1' ); + $result->addValue( null, null, 'unnamed 2' ); + + $result->addValue( null, 'deleteValue', '2' ); + $result->removeValue( null, 'deleteValue' ); + + $result->addValue( [ 'a', 'b' ], 'deleteValue', '3' ); + $result->removeValue( [ 'a', 'b', 'deleteValue' ], null, '3' ); + + $result->addContentValue( null, 'setContentValue', '3' ); + + $this->assertSame( [ + 'setValue' => '1', + 'unnamed 1', + 'unnamed 2', + 'a' => [ 'b' => [] ], + 'setContentValue' => '3', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_CONTENT => 'setContentValue', + ], $result->getResultData() ); + $this->assertSame( 20, $result->getSize() ); + + try { + $result->addValue( null, 'setValue', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to add element setValue=99, existing value is 1', + $ex->getMessage(), + 'Expected exception' + ); + } + + try { + $result->addContentValue( null, 'setContentValue2', '99' ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Attempting to set content element as setContentValue2 when setContentValue ' . + 'is already set as the content element', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->addValue( null, 'setValue', '99', ApiResult::OVERRIDE ); + $this->assertSame( '99', $result->getResultData( [ 'setValue' ] ) ); + + $result->addContentValue( null, 'setContentValue2', '99', ApiResult::OVERRIDE ); + $this->assertSame( 'setContentValue2', + $result->getResultData( [ ApiResult::META_CONTENT ] ) ); + + $result->reset(); + $this->assertSame( [ + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + $this->assertSame( 0, $result->getSize() ); + + $result->addValue( null, 'foo', 1 ); + $result->addValue( null, 'bar', 1 ); + $result->addValue( null, 'top', '2', ApiResult::ADD_ON_TOP ); + $result->addValue( null, null, '2', ApiResult::ADD_ON_TOP ); + $result->addValue( null, 'bottom', '2' ); + $result->addValue( null, 'foo', '2', ApiResult::OVERRIDE ); + $result->addValue( null, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP ); + $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom', ApiResult::META_TYPE ], + array_keys( $result->getResultData() ) ); + + $result->reset(); + $result->addValue( null, 'foo', [ 'bar' => 1 ] ); + $result->addValue( [ 'foo', 'top' ], 'x', 2, ApiResult::ADD_ON_TOP ); + $result->addValue( [ 'foo', 'bottom' ], 'x', 2 ); + $this->assertSame( [ 'top', 'bar', 'bottom' ], + array_keys( $result->getResultData( [ 'foo' ] ) ) ); + + $result->reset(); + $result->addValue( null, 'sub', [ 'foo' => 1 ] ); + $result->addValue( null, 'sub', [ 'bar' => 1 ] ); + $this->assertSame( [ + 'sub' => [ 'foo' => 1, 'bar' => 1 ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + try { + $result->addValue( null, 'sub', [ 'foo' => 2, 'baz' => 2 ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( RuntimeException $ex ) { + $this->assertSame( + 'Conflicting keys (foo) when attempting to merge element sub', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->reset(); + $title = Title::newFromText( "MediaWiki:Foobar" ); + $obj = new stdClass; + $obj->foo = 1; + $obj->bar = 2; + $result->addValue( null, 'title', $title ); + $result->addValue( null, 'obj', $obj ); + $this->assertSame( [ + 'title' => (string)$title, + 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + $fh = tmpfile(); + try { + $result->addValue( null, 'file', $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, null, $fh ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + $result->addValue( null, 'sub', $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $obj->file = $fh; + $result->addValue( null, null, $obj ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add resource(stream) to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + fclose( $fh ); + + try { + $result->addValue( null, 'inf', INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, null, INF ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, 'nan', NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + try { + $result->addValue( null, null, NAN ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->addValue( null, null, NAN, ApiResult::NO_VALIDATE ); + + try { + $result->addValue( null, null, NAN, ApiResult::NO_SIZE_CHECK ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $result->reset(); + $result->addParsedLimit( 'foo', 12 ); + $this->assertSame( [ + 'limits' => [ 'foo' => 12 ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + $result->addParsedLimit( 'foo', 13 ); + $this->assertSame( [ + 'limits' => [ 'foo' => 13 ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + $this->assertSame( null, $result->getResultData( [ 'foo', 'bar', 'baz' ] ) ); + $this->assertSame( 13, $result->getResultData( [ 'limits', 'foo' ] ) ); + try { + $result->getResultData( [ 'limits', 'foo', 'bar' ] ); + $this->fail( 'Expected exception not thrown' ); + } catch ( InvalidArgumentException $ex ) { + $this->assertSame( + 'Path limits.foo is not an array', + $ex->getMessage(), + 'Expected exception' + ); + } + + // Add two values and some metadata, but ensure metadata is not counted + $result = new ApiResult( 100 ); + $obj = [ 'attr' => '12345' ]; + ApiResult::setContentValue( $obj, 'content', '1234567890' ); + $this->assertTrue( $result->addValue( null, 'foo', $obj ) ); + $this->assertSame( 15, $result->getSize() ); + + $result = new ApiResult( 10 ); + $formatter = new ApiErrorFormatter( $result, Language::factory( 'en' ), 'none', false ); + $result->setErrorFormatter( $formatter ); + $this->assertFalse( $result->addValue( null, 'foo', '12345678901' ) ); + $this->assertTrue( $result->addValue( null, 'foo', '12345678901', ApiResult::NO_SIZE_CHECK ) ); + $this->assertSame( 0, $result->getSize() ); + $result->reset(); + $this->assertTrue( $result->addValue( null, 'foo', '1234567890' ) ); + $this->assertFalse( $result->addValue( null, 'foo', '1' ) ); + $result->removeValue( null, 'foo' ); + $this->assertTrue( $result->addValue( null, 'foo', '1' ) ); + + $result = new ApiResult( 10 ); + $obj = new ApiResultTestSerializableObject( 'ok' ); + $obj->foobar = 'foobaz'; + $this->assertTrue( $result->addValue( null, 'foo', $obj ) ); + $this->assertSame( 2, $result->getSize() ); + + $result = new ApiResult( 8388608 ); + $result2 = new ApiResult( 8388608 ); + $result2->addValue( null, 'foo', 'bar' ); + $result->addValue( null, 'baz', $result2 ); + $this->assertSame( [ + 'baz' => [ + 'foo' => 'bar', + ApiResult::META_TYPE => 'assoc', + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + $result = new ApiResult( 8388608 ); + $result->addValue( null, 'foo', "foo\x80bar" ); + $result->addValue( null, 'bar', "a\xcc\x81" ); + $result->addValue( null, 'baz', 74 ); + $result->addValue( null, null, "foo\x80bar" ); + $result->addValue( null, null, "a\xcc\x81" ); + $this->assertSame( [ + 'foo' => "foo\xef\xbf\xbdbar", + 'bar' => "\xc3\xa1", + 'baz' => 74, + 0 => "foo\xef\xbf\xbdbar", + 1 => "\xc3\xa1", + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + + $result = new ApiResult( 8388608 ); + $obj = new stdClass; + $obj->{'1'} = 'one'; + $arr = []; + $result->addValue( $arr, 'foo', $obj ); + $this->assertSame( [ + 'foo' => [ + 1 => 'one', + ApiResult::META_TYPE => 'assoc', + ], + ApiResult::META_TYPE => 'assoc', + ], $result->getResultData() ); + } + + /** + * @covers ApiResult + */ + public function testMetadata() { + $arr = [ 'foo' => [ 'bar' => [] ] ]; + $result = new ApiResult( 8388608 ); + $result->addValue( null, 'foo', [ 'bar' => [] ] ); + + $expect = [ + 'foo' => [ + 'bar' => [ + ApiResult::META_INDEXED_TAG_NAME => 'ritn', + ApiResult::META_TYPE => 'default', + ], + ApiResult::META_INDEXED_TAG_NAME => 'ritn', + ApiResult::META_TYPE => 'default', + ], + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar' ], + ApiResult::META_TYPE => 'array', + ]; + + ApiResult::setSubelementsList( $arr, 'foo' ); + ApiResult::setSubelementsList( $arr, [ 'bar', 'baz' ] ); + ApiResult::unsetSubelementsList( $arr, 'baz' ); + ApiResult::setIndexedTagNameRecursive( $arr, 'ritn' ); + ApiResult::setIndexedTagName( $arr, 'itn' ); + ApiResult::setPreserveKeysList( $arr, 'foo' ); + ApiResult::setPreserveKeysList( $arr, [ 'bar', 'baz' ] ); + ApiResult::unsetPreserveKeysList( $arr, 'baz' ); + ApiResult::setArrayTypeRecursive( $arr, 'default' ); + ApiResult::setArrayType( $arr, 'array' ); + $this->assertSame( $expect, $arr ); + + $result->addSubelementsList( null, 'foo' ); + $result->addSubelementsList( null, [ 'bar', 'baz' ] ); + $result->removeSubelementsList( null, 'baz' ); + $result->addIndexedTagNameRecursive( null, 'ritn' ); + $result->addIndexedTagName( null, 'itn' ); + $result->addPreserveKeysList( null, 'foo' ); + $result->addPreserveKeysList( null, [ 'bar', 'baz' ] ); + $result->removePreserveKeysList( null, 'baz' ); + $result->addArrayTypeRecursive( null, 'default' ); + $result->addArrayType( null, 'array' ); + $this->assertEquals( $expect, $result->getResultData() ); + + $arr = [ 'foo' => [ 'bar' => [] ] ]; + $expect = [ + 'foo' => [ + 'bar' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'bc', + ]; + ApiResult::setArrayTypeRecursive( $arr, 'kvp', 'key' ); + ApiResult::setArrayType( $arr, 'BCkvp', 'bc' ); + $this->assertSame( $expect, $arr ); + } + + /** + * @covers ApiResult + */ + public function testUtilityFunctions() { + $arr = [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + 'foo2' => (object)[ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + '_dummy2' => 'foobaz!', + ]; + $this->assertEquals( [ + 'foo' => [ + 'bar' => [], + 'bar2' => (object)[], + 'x' => 'ok', + ], + 'foo2' => (object)[ + 'bar' => [], + 'bar2' => (object)[], + 'x' => 'ok', + ], + '_dummy2' => 'foobaz!', + ], ApiResult::stripMetadata( $arr ), 'ApiResult::stripMetadata' ); + + $metadata = []; + $data = ApiResult::stripMetadataNonRecursive( $arr, $metadata ); + $this->assertEquals( [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + 'foo2' => (object)[ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + '_dummy2' => 'foobaz!', + ], $data, 'ApiResult::stripMetadataNonRecursive ($data)' ); + $this->assertEquals( [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + ], $metadata, 'ApiResult::stripMetadataNonRecursive ($metadata)' ); + + $metadata = null; + $data = ApiResult::stripMetadataNonRecursive( (object)$arr, $metadata ); + $this->assertEquals( (object)[ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + 'foo2' => (object)[ + 'bar' => [ '_dummy' => 'foobaz' ], + 'bar2' => (object)[ '_dummy' => 'foobaz' ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + '_dummy2' => 'foobaz!', + ], $data, 'ApiResult::stripMetadataNonRecursive on object ($data)' ); + $this->assertEquals( [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + ], $metadata, 'ApiResult::stripMetadataNonRecursive on object ($metadata)' ); + } + + /** + * @covers ApiResult + * @dataProvider provideTransformations + * @param string $label + * @param array $input + * @param array $transforms + * @param array|Exception $expect + */ + public function testTransformations( $label, $input, $transforms, $expect ) { + $result = new ApiResult( false ); + $result->addValue( null, 'test', $input ); + + if ( $expect instanceof Exception ) { + try { + $output = $result->getResultData( 'test', $transforms ); + $this->fail( 'Expected exception not thrown', $label ); + } catch ( Exception $ex ) { + $this->assertEquals( $ex, $expect, $label ); + } + } else { + $output = $result->getResultData( 'test', $transforms ); + $this->assertEquals( $expect, $output, $label ); + } + } + + public function provideTransformations() { + $kvp = function ( $keyKey, $key, $valKey, $value ) { + return [ + $keyKey => $key, + $valKey => $value, + ApiResult::META_PRESERVE_KEYS => [ $keyKey ], + ApiResult::META_CONTENT => $valKey, + ApiResult::META_TYPE => 'assoc', + ]; + }; + $typeArr = [ + 'defaultArray' => [ 2 => 'a', 0 => 'b', 1 => 'c' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c' ], + 'array' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'BCarray' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'BCassoc' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ 'x' => 'a', 'y' => 'b', 'z' => [ 'c' ], ApiResult::META_TYPE => 'kvp' ], + 'BCkvp' => [ 'x' => 'a', 'y' => 'b', + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ 'x' => 'a', 'y' => [ 'b' ], 'z' => [ 'c' => 'd' ], + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1 ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ]; + $stripArr = [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'baz' => [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + '_dummy' => 'foobaz', + '_dummy2' => 'foobaz!', + ]; + + return [ + [ + 'BC: META_BC_BOOLS', + [ + 'BCtrue' => true, + 'BCfalse' => false, + 'true' => true, + 'false' => false, + ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ], + ], + [ 'BC' => [] ], + [ + 'BCtrue' => '', + 'true' => true, + 'false' => false, + ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ], + ] + ], + [ + 'BC: META_BC_SUBELEMENTS', + [ + 'bc' => 'foo', + 'nobc' => 'bar', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + ], + [ 'BC' => [] ], + [ + 'bc' => [ + '*' => 'foo', + ApiResult::META_CONTENT => '*', + ApiResult::META_TYPE => 'assoc', + ], + 'nobc' => 'bar', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + ], + ], + [ + 'BC: META_CONTENT', + [ + 'content' => '!!!', + ApiResult::META_CONTENT => 'content', + ], + [ 'BC' => [] ], + [ + '*' => '!!!', + ApiResult::META_CONTENT => '*', + ], + ], + [ + 'BC: BCkvp type', + [ + 'foo' => 'foo value', + 'bar' => 'bar value', + '_baz' => 'baz value', + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + [ 'BC' => [] ], + [ + $kvp( 'key', 'foo', '*', 'foo value' ), + $kvp( 'key', 'bar', '*', 'bar value' ), + $kvp( 'key', '_baz', '*', 'baz value' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + ], + [ + 'BC: BCarray type', + [ + ApiResult::META_TYPE => 'BCarray', + ], + [ 'BC' => [] ], + [ + ApiResult::META_TYPE => 'default', + ], + ], + [ + 'BC: BCassoc type', + [ + ApiResult::META_TYPE => 'BCassoc', + ], + [ 'BC' => [] ], + [ + ApiResult::META_TYPE => 'default', + ], + ], + [ + 'BC: BCkvp exception', + [ + ApiResult::META_TYPE => 'BCkvp', + ], + [ 'BC' => [] ], + new UnexpectedValueException( + 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item' + ), + ], + [ + 'BC: nobool, no*, nosub', + [ + 'true' => true, + 'false' => false, + 'content' => 'content', + ApiResult::META_CONTENT => 'content', + 'bc' => 'foo', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + 'BCarray' => [ ApiResult::META_TYPE => 'BCarray' ], + 'BCassoc' => [ ApiResult::META_TYPE => 'BCassoc' ], + 'BCkvp' => [ + 'foo' => 'foo value', + 'bar' => 'bar value', + '_baz' => 'baz value', + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + ], + [ 'BC' => [ 'nobool', 'no*', 'nosub' ] ], + [ + 'true' => true, + 'false' => false, + 'content' => 'content', + 'bc' => 'foo', + 'BCarray' => [ ApiResult::META_TYPE => 'default' ], + 'BCassoc' => [ ApiResult::META_TYPE => 'default' ], + 'BCkvp' => [ + $kvp( 'key', 'foo', '*', 'foo value' ), + $kvp( 'key', 'bar', '*', 'bar value' ), + $kvp( 'key', '_baz', '*', 'baz value' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ '_baz' ], + ], + ApiResult::META_CONTENT => 'content', + ApiResult::META_BC_SUBELEMENTS => [ 'bc' ], + ], + ], + + [ + 'Types: Normal transform', + $typeArr, + [ 'Types' => [] ], + [ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ 'x' => 'a', 'y' => 'b', + 'z' => [ 'c', ApiResult::META_TYPE => 'array' ], + ApiResult::META_TYPE => 'assoc' + ], + 'BCkvp' => [ 'x' => 'a', 'y' => 'b', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + 'x' => 'a', + 'y' => [ 'b', ApiResult::META_TYPE => 'array' ], + 'z' => [ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ], + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: AssocAsObject', + $typeArr, + [ 'Types' => [ 'AssocAsObject' => true ] ], + (object)[ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => (object)[ 'x' => 'a', + 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', + 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => (object)[ 'x' => 'a', 'y' => 'b', + 'z' => [ 'c', ApiResult::META_TYPE => 'array' ], + ApiResult::META_TYPE => 'assoc' + ], + 'BCkvp' => (object)[ 'x' => 'a', 'y' => 'b', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => (object)[ + 'x' => 'a', + 'y' => [ 'b', ApiResult::META_TYPE => 'array' ], + 'z' => (object)[ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ], + ApiResult::META_TYPE => 'assoc', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: ArmorKVP', + $typeArr, + [ 'Types' => [ 'ArmorKVP' => 'name' ] ], + [ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ + $kvp( 'name', 'x', 'value', 'a' ), + $kvp( 'name', 'y', 'value', 'b' ), + $kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ), + ApiResult::META_TYPE => 'array' + ], + 'BCkvp' => [ + $kvp( 'key', 'x', 'value', 'a' ), + $kvp( 'key', 'y', 'value', 'b' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + $kvp( 'name', 'x', 'value', 'a' ), + $kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ), + [ + 'name' => 'z', + 'c' => 'd', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_PRESERVE_KEYS => [ 'name' ] + ], + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: ArmorKVP + BC', + $typeArr, + [ 'BC' => [], 'Types' => [ 'ArmorKVP' => 'name' ] ], + [ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'array' ], + 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ + $kvp( 'name', 'x', '*', 'a' ), + $kvp( 'name', 'y', '*', 'b' ), + $kvp( 'name', 'z', '*', [ 'c', ApiResult::META_TYPE => 'array' ] ), + ApiResult::META_TYPE => 'array' + ], + 'BCkvp' => [ + $kvp( 'key', 'x', '*', 'a' ), + $kvp( 'key', 'y', '*', 'b' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + $kvp( 'name', 'x', '*', 'a' ), + $kvp( 'name', 'y', '*', [ 'b', ApiResult::META_TYPE => 'array' ] ), + [ + 'name' => 'z', + 'c' => 'd', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_PRESERVE_KEYS => [ 'name' ] ], + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: ArmorKVP + AssocAsObject', + $typeArr, + [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ] ], + (object)[ + 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ], + 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b', + 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', + 0 => 'c', ApiResult::META_TYPE => 'assoc' + ], + 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ], + 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ], + 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ], + 'kvp' => [ + (object)$kvp( 'name', 'x', 'value', 'a' ), + (object)$kvp( 'name', 'y', 'value', 'b' ), + (object)$kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ), + ApiResult::META_TYPE => 'array' + ], + 'BCkvp' => [ + (object)$kvp( 'key', 'x', 'value', 'a' ), + (object)$kvp( 'key', 'y', 'value', 'b' ), + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_KEY_NAME => 'key', + ], + 'kvpmerge' => [ + (object)$kvp( 'name', 'x', 'value', 'a' ), + (object)$kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ), + (object)[ + 'name' => 'z', + 'c' => 'd', + ApiResult::META_TYPE => 'assoc', + ApiResult::META_PRESERVE_KEYS => [ 'name' ] + ], + ApiResult::META_TYPE => 'array', + ApiResult::META_KVP_MERGE => true, + ], + 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ], + 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ], + '_dummy' => 1, + ApiResult::META_PRESERVE_KEYS => [ '_dummy' ], + ApiResult::META_TYPE => 'assoc', + ], + ], + [ + 'Types: BCkvp exception', + [ + ApiResult::META_TYPE => 'BCkvp', + ], + [ 'Types' => [] ], + new UnexpectedValueException( + 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item' + ), + ], + + [ + 'Strip: With ArmorKVP + AssocAsObject transforms', + $typeArr, + [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ], 'Strip' => 'all' ], + (object)[ + 'defaultArray' => [ 'b', 'c', 'a' ], + 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b', 0 => 'c' ], + 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', 0 => 'c' ], + 'array' => [ 'a', 'c', 'b' ], + 'BCarray' => [ 'a', 'c', 'b' ], + 'BCassoc' => (object)[ 'a', 'b', 'c' ], + 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c' ], + 'kvp' => [ + (object)[ 'name' => 'x', 'value' => 'a' ], + (object)[ 'name' => 'y', 'value' => 'b' ], + (object)[ 'name' => 'z', 'value' => [ 'c' ] ], + ], + 'BCkvp' => [ + (object)[ 'key' => 'x', 'value' => 'a' ], + (object)[ 'key' => 'y', 'value' => 'b' ], + ], + 'kvpmerge' => [ + (object)[ 'name' => 'x', 'value' => 'a' ], + (object)[ 'name' => 'y', 'value' => [ 'b' ] ], + (object)[ 'name' => 'z', 'c' => 'd' ], + ], + 'emptyDefault' => [], + 'emptyAssoc' => (object)[], + '_dummy' => 1, + ], + ], + + [ + 'Strip: all', + $stripArr, + [ 'Strip' => 'all' ], + [ + 'foo' => [ + 'bar' => [], + 'baz' => [], + 'x' => 'ok', + ], + '_dummy2' => 'foobaz!', + ], + ], + [ + 'Strip: base', + $stripArr, + [ 'Strip' => 'base' ], + [ + 'foo' => [ + 'bar' => [ '_dummy' => 'foobaz' ], + 'baz' => [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ], + ApiResult::META_TYPE => 'array', + ], + 'x' => 'ok', + '_dummy' => 'foobaz', + ], + '_dummy2' => 'foobaz!', + ], + ], + [ + 'Strip: bc', + $stripArr, + [ 'Strip' => 'bc' ], + [ + 'foo' => [ + 'bar' => [], + 'baz' => [ + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ], + 'x' => 'ok', + ], + '_dummy2' => 'foobaz!', + ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ], + ApiResult::META_INDEXED_TAG_NAME => 'itn', + ], + ], + + [ + 'Custom transform', + [ + 'foo' => '?', + 'bar' => '?', + '_dummy' => '?', + '_dummy2' => '?', + '_dummy3' => '?', + ApiResult::META_CONTENT => 'foo', + ApiResult::META_PRESERVE_KEYS => [ '_dummy2', '_dummy3' ], + ], + [ + 'Custom' => [ $this, 'customTransform' ], + 'BC' => [], + 'Types' => [], + 'Strip' => 'all' + ], + [ + '*' => 'FOO', + 'bar' => 'BAR', + 'baz' => [ 'a', 'b' ], + '_dummy2' => '_DUMMY2', + '_dummy3' => '_DUMMY3', + ApiResult::META_CONTENT => 'bar', + ], + ], + ]; + } + + /** + * Custom transformer for testTransformations + * @param array &$data + * @param array &$metadata + */ + public function customTransform( &$data, &$metadata ) { + // Prevent recursion + if ( isset( $metadata['_added'] ) ) { + $metadata[ApiResult::META_TYPE] = 'array'; + return; + } + + foreach ( $data as $k => $v ) { + $data[$k] = strtoupper( $k ); + } + $data['baz'] = [ '_added' => 1, 'z' => 'b', 'y' => 'a' ]; + $metadata[ApiResult::META_PRESERVE_KEYS][0] = '_dummy'; + $data[ApiResult::META_CONTENT] = 'bar'; + } + + /** + * @covers ApiResult + */ + public function testAddMetadataToResultVars() { + $arr = [ + 'a' => "foo", + 'b' => false, + 'c' => 10, + 'sequential_numeric_keys' => [ 'a', 'b', 'c' ], + 'non_sequential_numeric_keys' => [ 'a', 'b', 4 => 'c' ], + 'string_keys' => [ + 'one' => 1, + 'two' => 2 + ], + 'object_sequential_keys' => (object)[ 'a', 'b', 'c' ], + '_type' => "should be overwritten in result", + ]; + $this->assertSame( [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ + 'a', 'b', 'c', + 'sequential_numeric_keys', 'non_sequential_numeric_keys', + 'string_keys', 'object_sequential_keys' + ], + ApiResult::META_BC_BOOLS => [ 'b' ], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 'a' => "foo", + 'b' => false, + 'c' => 10, + 'sequential_numeric_keys' => [ + ApiResult::META_TYPE => 'array', + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'value', + 0 => 'a', + 1 => 'b', + 2 => 'c', + ], + 'non_sequential_numeric_keys' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ 0, 1, 4 ], + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 0 => 'a', + 1 => 'b', + 4 => 'c', + ], + 'string_keys' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ 'one', 'two' ], + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 'one' => 1, + 'two' => 2, + ], + 'object_sequential_keys' => [ + ApiResult::META_TYPE => 'kvp', + ApiResult::META_KVP_KEY_NAME => 'key', + ApiResult::META_PRESERVE_KEYS => [ 0, 1, 2 ], + ApiResult::META_BC_BOOLS => [], + ApiResult::META_INDEXED_TAG_NAME => 'var', + 0 => 'a', + 1 => 'b', + 2 => 'c', + ], + ], ApiResult::addMetadataToResultVars( $arr ) ); + } + + public function testObjectSerialization() { + $arr = []; + ApiResult::setValue( $arr, 'foo', (object)[ 'a' => 1, 'b' => 2 ] ); + $this->assertSame( [ + 'a' => 1, + 'b' => 2, + ApiResult::META_TYPE => 'assoc', + ], $arr['foo'] ); + + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestStringifiableObject() ); + $this->assertSame( 'Ok', $arr['foo'] ); + + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( 'Ok' ) ); + $this->assertSame( 'Ok', $arr['foo'] ); + + try { + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( + new ApiResultTestStringifiableObject() + ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'ApiResultTestSerializableObject::serializeForApiResult() ' . + 'returned an object of class ApiResultTestStringifiableObject', + $ex->getMessage(), + 'Expected exception' + ); + } + + try { + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( NAN ) ); + $this->fail( 'Expected exception not thrown' ); + } catch ( UnexpectedValueException $ex ) { + $this->assertSame( + 'ApiResultTestSerializableObject::serializeForApiResult() ' . + 'returned an invalid value: Cannot add non-finite floats to ApiResult', + $ex->getMessage(), + 'Expected exception' + ); + } + + $arr = []; + ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( + [ + 'one' => new ApiResultTestStringifiableObject( '1' ), + 'two' => new ApiResultTestSerializableObject( 2 ), + ] + ) ); + $this->assertSame( [ + 'one' => '1', + 'two' => 2, + ], $arr['foo'] ); + } +} + +class ApiResultTestStringifiableObject { + private $ret; + + public function __construct( $ret = 'Ok' ) { + $this->ret = $ret; + } + + public function __toString() { + return $this->ret; + } +} + +class ApiResultTestSerializableObject { + private $ret; + + public function __construct( $ret ) { + $this->ret = $ret; + } + + public function __toString() { + return "Fail"; + } + + public function serializeForApiResult() { + return $this->ret; + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php b/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php new file mode 100644 index 00000000..a4ca8a10 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php @@ -0,0 +1,117 @@ +<?php + +/** + * Tests for action=revisiondelete + * @covers APIRevisionDelete + * @group API + * @group medium + * @group Database + */ +class ApiRevisionDeleteTest extends ApiTestCase { + + public static $page = 'Help:ApiRevDel_test'; + public $revs = []; + + protected function setUp() { + // Needs to be before setup since this gets cached + $this->mergeMwGlobalArrayValue( + 'wgGroupPermissions', + [ 'sysop' => [ 'deleterevision' => true ] ] + ); + parent::setUp(); + // Make a few edits for us to play with + for ( $i = 1; $i <= 5; $i++ ) { + self::editPage( self::$page, MWCryptRand::generateHex( 10 ), 'summary' ); + $this->revs[] = Title::newFromText( self::$page ) + ->getLatestRevID( Title::GAID_FOR_UPDATE ); + } + } + + public function testHidingRevisions() { + $user = self::$users['sysop']->getUser(); + $revid = array_shift( $this->revs ); + $out = $this->doApiRequest( [ + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'hide' => 'content|user|comment', + 'token' => $user->getEditToken(), + ] ); + // Check the output + $out = $out[0]['revisiondelete']; + $this->assertEquals( $out['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out ); + $item = $out['items'][0]; + $this->assertTrue( $item['userhidden'], 'userhidden' ); + $this->assertTrue( $item['commenthidden'], 'commenthidden' ); + $this->assertTrue( $item['texthidden'], 'texthidden' ); + $this->assertEquals( $item['id'], $revid ); + + // Now check that that revision was actually hidden + $rev = Revision::newFromId( $revid ); + $this->assertEquals( $rev->getContent( Revision::FOR_PUBLIC ), null ); + $this->assertEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' ); + $this->assertEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 ); + + // Now test unhiding! + $out2 = $this->doApiRequest( [ + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'show' => 'content|user|comment', + 'token' => $user->getEditToken(), + ] ); + + // Check the output + $out2 = $out2[0]['revisiondelete']; + $this->assertEquals( $out2['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out2 ); + $item = $out2['items'][0]; + + $this->assertFalse( $item['userhidden'], 'userhidden' ); + $this->assertFalse( $item['commenthidden'], 'commenthidden' ); + $this->assertFalse( $item['texthidden'], 'texthidden' ); + + $this->assertEquals( $item['id'], $revid ); + + $rev = Revision::newFromId( $revid ); + $this->assertNotEquals( $rev->getContent( Revision::FOR_PUBLIC ), null ); + $this->assertNotEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' ); + $this->assertNotEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 ); + } + + public function testUnhidingOutput() { + $user = self::$users['sysop']->getUser(); + $revid = array_shift( $this->revs ); + // Hide revisions + $this->doApiRequest( [ + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'hide' => 'content|user|comment', + 'token' => $user->getEditToken(), + ] ); + + $out = $this->doApiRequest( [ + 'action' => 'revisiondelete', + 'type' => 'revision', + 'target' => self::$page, + 'ids' => $revid, + 'show' => 'comment', + 'token' => $user->getEditToken(), + ] ); + $out = $out[0]['revisiondelete']; + $this->assertEquals( $out['status'], 'Success' ); + $this->assertArrayHasKey( 'items', $out ); + $item = $out['items'][0]; + // Check it has userhidden & texthidden + // but not commenthidden + $this->assertTrue( $item['userhidden'], 'userhidden' ); + $this->assertFalse( $item['commenthidden'], 'commenthidden' ); + $this->assertTrue( $item['texthidden'], 'texthidden' ); + $this->assertEquals( $item['id'], $revid ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php new file mode 100644 index 00000000..dacd48f6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php @@ -0,0 +1,51 @@ +<?php +use MediaWiki\MediaWikiServices; + +/** + * @author Addshore + * @covers ApiSetNotificationTimestamp + * @group API + * @group medium + * @group Database + */ +class ApiSetNotificationTimestampIntegrationTest extends ApiTestCase { + + protected function setUp() { + parent::setUp(); + self::$users[__CLASS__] = new TestUser( __CLASS__ ); + } + + public function testStuff() { + $user = self::$users[__CLASS__]->getUser(); + $page = WikiPage::factory( Title::newFromText( 'UTPage' ) ); + + $user->addWatch( $page->getTitle() ); + + $result = $this->doApiRequestWithToken( + [ + 'action' => 'setnotificationtimestamp', + 'timestamp' => '20160101020202', + 'pageids' => $page->getId(), + ], + null, + $user + ); + + $this->assertEquals( + [ + 'batchcomplete' => true, + 'setnotificationtimestamp' => [ + [ 'ns' => 0, 'title' => 'UTPage', 'notificationtimestamp' => '2016-01-01T02:02:02Z' ] + ], + ], + $result[0] + ); + + $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore(); + $this->assertEquals( + $watchedItemStore->getNotificationTimestampsBatch( $user, [ $page->getTitle() ] ), + [ [ 'UTPage' => '20160101020202' ] ] + ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php b/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php new file mode 100644 index 00000000..60cda090 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php @@ -0,0 +1,27 @@ +<?php + +/** + * @covers ApiStashEdit + * @group API + * @group medium + * @group Database + */ +class ApiStashEditTest extends ApiTestCase { + + public function testBasicEdit() { + $apiResult = $this->doApiRequestWithToken( + [ + 'action' => 'stashedit', + 'title' => 'ApistashEdit_Page', + 'contentmodel' => 'wikitext', + 'contentformat' => 'text/x-wiki', + 'text' => 'Text for ' . __METHOD__ . ' page', + 'baserevid' => 0, + ] + ); + $apiResult = $apiResult[0]; + $this->assertArrayHasKey( 'stashedit', $apiResult ); + $this->assertEquals( 'stashed', $apiResult['stashedit']['status'] ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestCase.php b/www/wiki/tests/phpunit/includes/api/ApiTestCase.php new file mode 100644 index 00000000..974e9a2d --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiTestCase.php @@ -0,0 +1,260 @@ +<?php + +use MediaWiki\Session\SessionManager; + +abstract class ApiTestCase extends MediaWikiLangTestCase { + protected static $apiUrl; + + protected static $errorFormatter = null; + + /** + * @var ApiTestContext + */ + protected $apiContext; + + protected function setUp() { + global $wgServer; + + parent::setUp(); + self::$apiUrl = $wgServer . wfScript( 'api' ); + + ApiQueryInfo::resetTokenCache(); // tokens are invalid because we cleared the session + + self::$users = [ + 'sysop' => static::getTestSysop(), + 'uploader' => static::getTestUser(), + ]; + + $this->setMwGlobals( [ + 'wgAuth' => new MediaWiki\Auth\AuthManagerAuthPlugin, + 'wgRequest' => new FauxRequest( [] ), + 'wgUser' => self::$users['sysop']->getUser(), + ] ); + + $this->apiContext = new ApiTestContext(); + } + + protected function tearDown() { + // Avoid leaking session over tests + MediaWiki\Session\SessionManager::getGlobalSession()->clear(); + + parent::tearDown(); + } + + /** + * Edits or creates a page/revision + * @param string $pageName Page title + * @param string $text Content of the page + * @param string $summary Optional summary string for the revision + * @param int $defaultNs Optional namespace id + * @return array Array as returned by WikiPage::doEditContent() + */ + protected function editPage( $pageName, $text, $summary = '', $defaultNs = NS_MAIN ) { + $title = Title::newFromText( $pageName, $defaultNs ); + $page = WikiPage::factory( $title ); + + return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary ); + } + + /** + * Revision-deletes a revision. + * + * @param Revision|int $rev Revision to delete + * @param array $value Keys are Revision::DELETED_* flags. Values are 1 to set the bit, 0 to + * clear, -1 to leave alone. (All other values also clear the bit.) + * @param string $comment Deletion comment + */ + protected function revisionDelete( + $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = '' + ) { + if ( is_int( $rev ) ) { + $rev = Revision::newFromId( $rev ); + } + RevisionDeleter::createList( + 'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ] + )->setVisibility( [ + 'value' => $value, + 'comment' => $comment, + ] ); + } + + /** + * Does the API request and returns the result. + * + * The returned value is an array containing + * - the result data (array) + * - the request (WebRequest) + * - the session data of the request (array) + * - if $appendModule is true, the Api module $module + * + * @param array $params + * @param array|null $session + * @param bool $appendModule + * @param User|null $user + * @param string|null $tokenType Set to a string like 'csrf' to send an + * appropriate token + * + * @throws ApiUsageException + * @return array + */ + protected function doApiRequest( array $params, array $session = null, + $appendModule = false, User $user = null, $tokenType = null + ) { + global $wgRequest, $wgUser; + + if ( is_null( $session ) ) { + // re-use existing global session by default + $session = $wgRequest->getSessionArray(); + } + + $sessionObj = SessionManager::singleton()->getEmptySession(); + + if ( $session !== null ) { + foreach ( $session as $key => $value ) { + $sessionObj->set( $key, $value ); + } + } + + // set up global environment + if ( $user ) { + $wgUser = $user; + } + + if ( $tokenType !== null ) { + if ( $tokenType === 'auto' ) { + $tokenType = ( new ApiMain() )->getModuleManager() + ->getModule( $params['action'], 'action' )->needsToken(); + } + $params['token'] = ApiQueryTokens::getToken( + $wgUser, $sessionObj, ApiQueryTokens::getTokenTypeSalts()[$tokenType] + )->toString(); + } + + $wgRequest = new FauxRequest( $params, true, $sessionObj ); + RequestContext::getMain()->setRequest( $wgRequest ); + RequestContext::getMain()->setUser( $wgUser ); + MediaWiki\Auth\AuthManager::resetCache(); + + // set up local environment + $context = $this->apiContext->newTestContext( $wgRequest, $wgUser ); + + $module = new ApiMain( $context, true ); + + // run it! + $module->execute(); + + // construct result + $results = [ + $module->getResult()->getResultData( null, [ 'Strip' => 'all' ] ), + $context->getRequest(), + $context->getRequest()->getSessionArray() + ]; + + if ( $appendModule ) { + $results[] = $module; + } + + return $results; + } + + /** + * Convenience function to access the token parameter of doApiRequest() + * more succinctly. + * + * @param array $params Key-value API params + * @param array|null $session Session array + * @param User|null $user A User object for the context + * @param string $tokenType Which token type to pass + * @return array Result of the API call + */ + protected function doApiRequestWithToken( array $params, array $session = null, + User $user = null, $tokenType = 'auto' + ) { + return $this->doApiRequest( $params, $session, false, $user, $tokenType ); + } + + /** + * Previously this would do API requests to log in, as well as setting $wgUser and the request + * context's user. The API requests are unnecessary, and the global-setting is unwanted, so + * this method should not be called. Instead, pass appropriate User values directly to + * functions that need them. For functions that still rely on $wgUser, set that directly. If + * you just want to log in the test sysop user, don't do anything -- that's the default. + * + * @param TestUser|string $testUser Object, or key to self::$users such as 'sysop' or 'uploader' + * @deprecated since 1.31 + */ + protected function doLogin( $testUser = null ) { + global $wgUser; + + if ( $testUser === null ) { + $testUser = static::getTestSysop(); + } elseif ( is_string( $testUser ) && array_key_exists( $testUser, self::$users ) ) { + $testUser = self::$users[$testUser]; + } elseif ( !$testUser instanceof TestUser ) { + throw new MWException( "Can't log in to undefined user $testUser" ); + } + + $wgUser = $testUser->getUser(); + RequestContext::getMain()->setUser( $wgUser ); + } + + protected function getTokenList( TestUser $user, $session = null ) { + $data = $this->doApiRequest( [ + 'action' => 'tokens', + 'type' => 'edit|delete|protect|move|block|unblock|watch' + ], $session, false, $user->getUser() ); + + if ( !array_key_exists( 'tokens', $data[0] ) ) { + throw new MWException( 'Api failed to return a token list' ); + } + + return $data[0]['tokens']; + } + + protected static function getErrorFormatter() { + if ( self::$errorFormatter === null ) { + self::$errorFormatter = new ApiErrorFormatter( + new ApiResult( false ), + Language::factory( 'en' ), + 'none' + ); + } + return self::$errorFormatter; + } + + public static function apiExceptionHasCode( ApiUsageException $ex, $code ) { + return (bool)array_filter( + self::getErrorFormatter()->arrayFromStatus( $ex->getStatusValue() ), + function ( $e ) use ( $code ) { + return is_array( $e ) && $e['code'] === $code; + } + ); + } + + /** + * @coversNothing + */ + public function testApiTestGroup() { + $groups = PHPUnit_Util_Test::getGroups( static::class ); + $constraint = PHPUnit_Framework_Assert::logicalOr( + $this->contains( 'medium' ), + $this->contains( 'large' ) + ); + $this->assertThat( $groups, $constraint, + 'ApiTestCase::setUp can be slow, tests must be "medium" or "large"' + ); + } + + /** + * Expect an ApiUsageException to be thrown with the given parameters, which are the same as + * ApiUsageException::newWithMessage()'s parameters. This allows checking for an exception + * whose text is given by a message key instead of text, so as not to hard-code the message's + * text into test code. + */ + protected function setExpectedApiException( + $msg, $code = null, array $data = null, $httpCode = 0 + ) { + $expected = ApiUsageException::newWithMessage( null, $msg, $code, $data, $httpCode ); + $this->setExpectedException( ApiUsageException::class, $expected->getMessage() ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php b/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php new file mode 100644 index 00000000..3670fad8 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php @@ -0,0 +1,8 @@ +<?php + +/** + * For backward compatibility since 1.31 + */ +abstract class ApiTestCaseUpload extends ApiUploadTestCase { + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestContext.php b/www/wiki/tests/phpunit/includes/api/ApiTestContext.php new file mode 100644 index 00000000..17dad1fa --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiTestContext.php @@ -0,0 +1,21 @@ +<?php + +class ApiTestContext extends RequestContext { + + /** + * Returns a DerivativeContext with the request variables in place + * + * @param WebRequest $request WebRequest request object including parameters and session + * @param User|null $user User or null + * @return DerivativeContext + */ + public function newTestContext( WebRequest $request, User $user = null ) { + $context = new DerivativeContext( $this ); + $context->setRequest( $request ); + if ( $user !== null ) { + $context->setUser( $user ); + } + + return $context; + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php b/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php new file mode 100644 index 00000000..1f7c00b0 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php @@ -0,0 +1,40 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiTokens + */ +class ApiTokensTest extends ApiTestCase { + + public function testGettingToken() { + foreach ( self::$users as $user ) { + $this->runTokenTest( $user ); + } + } + + protected function runTokenTest( TestUser $user ) { + $tokens = $this->getTokenList( $user ); + + $rights = $user->getUser()->getRights(); + + $this->assertArrayHasKey( 'edittoken', $tokens ); + $this->assertArrayHasKey( 'movetoken', $tokens ); + + if ( isset( $rights['delete'] ) ) { + $this->assertArrayHasKey( 'deletetoken', $tokens ); + } + + if ( isset( $rights['block'] ) ) { + $this->assertArrayHasKey( 'blocktoken', $tokens ); + $this->assertArrayHasKey( 'unblocktoken', $tokens ); + } + + if ( isset( $rights['protect'] ) ) { + $this->assertArrayHasKey( 'protecttoken', $tokens ); + } + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php b/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php new file mode 100644 index 00000000..d20de0dc --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php @@ -0,0 +1,23 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiUnblock + */ +class ApiUnblockTest extends ApiTestCase { + /** + * @expectedException ApiUsageException + */ + public function testWithNoToken() { + $this->doApiRequest( + [ + 'action' => 'unblock', + 'user' => 'UTApiBlockee', + 'reason' => 'Some reason', + ] + ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php b/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php new file mode 100644 index 00000000..41c9aed4 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php @@ -0,0 +1,560 @@ +<?php +/** + * n.b. Ensure that you can write to the images/ directory as the + * user that will run tests. + * + * Note for reviewers: this intentionally duplicates functionality already in + * "ApiSetup" and so on. This framework works better IMO and has less + * strangeness (such as test cases inheriting from "ApiSetup"...) (and in the + * case of the other Upload tests, this flat out just actually works... ) + * + * @todo Port the other Upload tests, and other API tests to this framework + * + * @todo Broken test, reports false errors from time to time. + * See https://phabricator.wikimedia.org/T28169 + * + * @todo This is pretty sucky... needs to be prettified. + * + * @group API + * @group Database + * @group medium + * @group Broken + * + * @covers ApiUpload + */ +class ApiUploadTest extends ApiUploadTestCase { + /** + * Testing login + * XXX this is a funny way of getting session context + */ + public function testLogin() { + $user = self::$users['uploader']; + $userName = $user->getUser()->getName(); + $password = $user->getPassword(); + + $params = [ + 'action' => 'login', + 'lgname' => $userName, + 'lgpassword' => $password + ]; + list( $result, , $session ) = $this->doApiRequest( $params ); + $this->assertArrayHasKey( "login", $result ); + $this->assertArrayHasKey( "result", $result['login'] ); + $this->assertEquals( "NeedToken", $result['login']['result'] ); + $token = $result['login']['token']; + + $params = [ + 'action' => 'login', + 'lgtoken' => $token, + 'lgname' => $userName, + 'lgpassword' => $password + ]; + list( $result, , $session ) = $this->doApiRequest( $params, $session ); + $this->assertArrayHasKey( "login", $result ); + $this->assertArrayHasKey( "result", $result['login'] ); + $this->assertEquals( "Success", $result['login']['result'] ); + + $this->assertNotEmpty( $session, 'API Login must return a session' ); + + return $session; + } + + /** + * @depends testLogin + */ + public function testUploadRequiresToken( $session ) { + $exception = false; + try { + $this->doApiRequest( [ + 'action' => 'upload' + ] ); + } catch ( ApiUsageException $e ) { + $exception = true; + $this->assertContains( 'The "token" parameter must be set', $e->getMessage() ); + } + $this->assertTrue( $exception, "Got exception" ); + } + + /** + * @depends testLogin + */ + public function testUploadMissingParams( $session ) { + $exception = false; + try { + $this->doApiRequestWithToken( [ + 'action' => 'upload', + ], $session, self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $exception = true; + $this->assertEquals( + 'One of the parameters "filekey", "file" and "url" is required.', + $e->getMessage() + ); + } + $this->assertTrue( $exception, "Got exception" ); + } + + /** + * @depends testLogin + */ + public function testUpload( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $filePath = $filePaths[0]; + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = [ + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ]; + + $exception = false; + try { + list( $result, , ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFileName( $fileName ); + } + + /** + * @depends testLogin + */ + public function testUploadZeroLength( $session ) { + $mimeType = 'image/png'; + + $filePath = $this->getNewTempFile(); + $fileName = "apiTestUploadZeroLength.png"; + + $this->deleteFileByFileName( $fileName ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = [ + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ]; + + $exception = false; + try { + $this->doApiRequestWithToken( $params, $session, self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $this->assertContains( 'The file you submitted was empty', $e->getMessage() ); + $exception = true; + } + $this->assertTrue( $exception ); + + // clean up + $this->deleteFileByFileName( $fileName ); + } + + /** + * @depends testLogin + */ + public function testUploadSameFileName( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 2, $extension, $this->getNewTempDirectory() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + // we'll reuse this filename + /** @var array $filePaths */ + $fileName = basename( $filePaths[0] ); + + // clear any other files with the same name + $this->deleteFileByFileName( $fileName ); + + // we reuse these params + $params = [ + 'action' => 'upload', + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ]; + + // first upload .... should succeed + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // second upload with the same name (but different content) + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[1] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Warning', $result['upload']['result'] ); + $this->assertTrue( isset( $result['upload']['warnings'] ) ); + $this->assertTrue( isset( $result['upload']['warnings']['exists'] ) ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFileName( $fileName ); + } + + /** + * @depends testLogin + */ + public function testUploadSameContent( $session ) { + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $fileNames[0] = basename( $filePaths[0] ); + $fileNames[1] = "SameContentAs" . $fileNames[0]; + + // clear any other files with the same name or content + $this->deleteFileByContent( $filePaths[0] ); + $this->deleteFileByFileName( $fileNames[0] ); + $this->deleteFileByFileName( $fileNames[1] ); + + // first upload .... should succeed + + $params = [ + 'action' => 'upload', + 'filename' => $fileNames[0], + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for " . $fileNames[0], + ]; + + if ( !$this->fakeUploadFile( 'file', $fileNames[0], $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // second upload with the same content (but different name) + + if ( !$this->fakeUploadFile( 'file', $fileNames[1], $mimeType, $filePaths[0] ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = [ + 'action' => 'upload', + 'filename' => $fileNames[1], + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for " . $fileNames[1], + ]; + + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Warning', $result['upload']['result'] ); + $this->assertTrue( isset( $result['upload']['warnings'] ) ); + $this->assertTrue( isset( $result['upload']['warnings']['duplicate'] ) ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFileName( $fileNames[0] ); + $this->deleteFileByFileName( $fileNames[1] ); + } + + /** + * @depends testLogin + */ + public function testUploadStash( $session ) { + $this->setMwGlobals( [ + 'wgUser' => self::$users['uploader']->getUser(), // @todo FIXME: still used somewhere + ] ); + + $extension = 'png'; + $mimeType = 'image/png'; + + try { + $randomImageGenerator = new RandomImageGenerator(); + $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + /** @var array $filePaths */ + $filePath = $filePaths[0]; + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) { + $this->markTestIncomplete( "Couldn't upload file!\n" ); + } + + $params = [ + 'action' => 'upload', + 'stash' => 1, + 'filename' => $fileName, + 'file' => 'dummy content', + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName", + ]; + + $exception = false; + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertFalse( $exception ); + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + $this->assertEquals( $result['upload']['sessionkey'], $result['upload']['filekey'] ); + $filekey = $result['upload']['filekey']; + + // it should be visible from Special:UploadStash + // XXX ...but how to test this, with a fake WebRequest with the session? + + // now we should try to release the file from stash + $params = [ + 'action' => 'upload', + 'filekey' => $filekey, + 'filename' => $fileName, + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName, altered", + ]; + + $this->clearFakeUploads(); + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception, "No ApiUsageException exception." ); + + // clean up + $this->deleteFileByFileName( $fileName ); + } + + /** + * @depends testLogin + */ + public function testUploadChunks( $session ) { + $this->setMwGlobals( [ + // @todo FIXME: still used somewhere + 'wgUser' => self::$users['uploader']->getUser(), + ] ); + + $chunkSize = 1048576; + // Download a large image file + // (using RandomImageGenerator for large files is not stable) + // @todo Don't download files from wikimedia.org + $mimeType = 'image/jpeg'; + $url = 'http://upload.wikimedia.org/wikipedia/commons/' + . 'e/ed/Oberaargletscher_from_Oberaar%2C_2010_07.JPG'; + $filePath = $this->getNewTempDirectory() . '/Oberaargletscher_from_Oberaar.jpg'; + try { + copy( $url, $filePath ); + } catch ( Exception $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + + $fileSize = filesize( $filePath ); + $fileName = basename( $filePath ); + + $this->deleteFileByFileName( $fileName ); + $this->deleteFileByContent( $filePath ); + + // Base upload params: + $params = [ + 'action' => 'upload', + 'stash' => 1, + 'filename' => $fileName, + 'filesize' => $fileSize, + 'offset' => 0, + ]; + + // Upload chunks + $chunkSessionKey = false; + $resultOffset = 0; + // Open the file: + Wikimedia\suppressWarnings(); + $handle = fopen( $filePath, "r" ); + Wikimedia\restoreWarnings(); + + if ( $handle === false ) { + $this->markTestIncomplete( "could not open file: $filePath" ); + } + + while ( !feof( $handle ) ) { + // Get the current chunk + Wikimedia\suppressWarnings(); + $chunkData = fread( $handle, $chunkSize ); + Wikimedia\restoreWarnings(); + + // Upload the current chunk into the $_FILE object: + $this->fakeUploadChunk( 'chunk', 'blob', $mimeType, $chunkData ); + + // Check for chunkSessionKey + if ( !$chunkSessionKey ) { + // Upload fist chunk ( and get the session key ) + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + // Make sure we got a valid chunk continue: + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + // If we don't get a session key mark test incomplete. + if ( !isset( $result['upload']['filekey'] ) ) { + $this->markTestIncomplete( "no filekey provided" ); + } + $chunkSessionKey = $result['upload']['filekey']; + $this->assertEquals( 'Continue', $result['upload']['result'] ); + // First chunk should have chunkSize == offset + $this->assertEquals( $chunkSize, $result['upload']['offset'] ); + $resultOffset = $result['upload']['offset']; + continue; + } + // Filekey set to chunk session + $params['filekey'] = $chunkSessionKey; + // Update the offset ( always add chunkSize for subquent chunks + // should be in-sync with $result['upload']['offset'] ) + $params['offset'] += $chunkSize; + // Make sure param offset is insync with resultOffset: + $this->assertEquals( $resultOffset, $params['offset'] ); + // Upload current chunk + try { + list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $this->markTestIncomplete( $e->getMessage() ); + } + // Make sure we got a valid chunk continue: + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + + // Check if we were on the last chunk: + if ( $params['offset'] + $chunkSize >= $fileSize ) { + $this->assertEquals( 'Success', $result['upload']['result'] ); + break; + } else { + $this->assertEquals( 'Continue', $result['upload']['result'] ); + // update $resultOffset + $resultOffset = $result['upload']['offset']; + } + } + fclose( $handle ); + + // Check that we got a valid file result: + wfDebug( __METHOD__ + . " hohoh filesize {$fileSize} info {$result['upload']['imageinfo']['size']}\n\n" ); + $this->assertEquals( $fileSize, $result['upload']['imageinfo']['size'] ); + $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] ); + $this->assertTrue( isset( $result['upload']['filekey'] ) ); + $filekey = $result['upload']['filekey']; + + // Now we should try to release the file from stash + $params = [ + 'action' => 'upload', + 'filekey' => $filekey, + 'filename' => $fileName, + 'comment' => 'dummy comment', + 'text' => "This is the page text for $fileName, altered", + ]; + $this->clearFakeUploads(); + $exception = false; + try { + list( $result ) = $this->doApiRequestWithToken( $params, $session, + self::$users['uploader']->getUser() ); + } catch ( ApiUsageException $e ) { + $exception = true; + } + $this->assertTrue( isset( $result['upload'] ) ); + $this->assertEquals( 'Success', $result['upload']['result'] ); + $this->assertFalse( $exception ); + + // clean up + $this->deleteFileByFileName( $fileName ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php b/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php new file mode 100644 index 00000000..3c7efd57 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php @@ -0,0 +1,153 @@ +<?php + +/** + * Abstract class to support upload tests + */ +abstract class ApiUploadTestCase extends ApiTestCase { + /** + * Fixture -- run before every test + */ + protected function setUp() { + parent::setUp(); + + $this->setMwGlobals( [ + 'wgEnableUploads' => true, + 'wgEnableAPI' => true, + ] ); + + $this->clearFakeUploads(); + } + + /** + * Helper function -- remove files and associated articles by Title + * + * @param Title $title Title to be removed + * + * @return bool + */ + public function deleteFileByTitle( $title ) { + if ( $title->exists() ) { + $file = wfFindFile( $title, [ 'ignoreRedirect' => true ] ); + $noOldArchive = ""; // yes this really needs to be set this way + $comment = "removing for test"; + $restrictDeletedVersions = false; + $status = FileDeleteForm::doDelete( + $title, + $file, + $noOldArchive, + $comment, + $restrictDeletedVersions + ); + + if ( !$status->isGood() ) { + return false; + } + + $page = WikiPage::factory( $title ); + $page->doDeleteArticle( "removing for test" ); + + // see if it now doesn't exist; reload + $title = Title::newFromText( $title->getText(), NS_FILE ); + } + + return !( $title && $title instanceof Title && $title->exists() ); + } + + /** + * Helper function -- remove files and associated articles with a particular filename + * + * @param string $fileName Filename to be removed + * + * @return bool + */ + public function deleteFileByFileName( $fileName ) { + return $this->deleteFileByTitle( Title::newFromText( $fileName, NS_FILE ) ); + } + + /** + * Helper function -- given a file on the filesystem, find matching + * content in the db (and associated articles) and remove them. + * + * @param string $filePath Path to file on the filesystem + * + * @return bool + */ + public function deleteFileByContent( $filePath ) { + $hash = FSFile::getSha1Base36FromPath( $filePath ); + $dupes = RepoGroup::singleton()->findBySha1( $hash ); + $success = true; + foreach ( $dupes as $dupe ) { + $success &= $this->deleteFileByTitle( $dupe->getTitle() ); + } + + return $success; + } + + /** + * Fake an upload by dumping the file into temp space, and adding info to $_FILES. + * (This is what PHP would normally do). + * + * @param string $fieldName Name this would have in the upload form + * @param string $fileName Name to title this + * @param string $type MIME type + * @param string $filePath Path where to find file contents + * + * @throws Exception + * @return bool + */ + function fakeUploadFile( $fieldName, $fileName, $type, $filePath ) { + $tmpName = $this->getNewTempFile(); + if ( !file_exists( $filePath ) ) { + throw new Exception( "$filePath doesn't exist!" ); + } + + if ( !copy( $filePath, $tmpName ) ) { + throw new Exception( "couldn't copy $filePath to $tmpName" ); + } + + clearstatcache(); + $size = filesize( $tmpName ); + if ( $size === false ) { + throw new Exception( "couldn't stat $tmpName" ); + } + + $_FILES[$fieldName] = [ + 'name' => $fileName, + 'type' => $type, + 'tmp_name' => $tmpName, + 'size' => $size, + 'error' => null + ]; + + return true; + } + + function fakeUploadChunk( $fieldName, $fileName, $type, & $chunkData ) { + $tmpName = $this->getNewTempFile(); + // copy the chunk data to temp location: + if ( !file_put_contents( $tmpName, $chunkData ) ) { + throw new Exception( "couldn't copy chunk data to $tmpName" ); + } + + clearstatcache(); + $size = filesize( $tmpName ); + if ( $size === false ) { + throw new Exception( "couldn't stat $tmpName" ); + } + + $_FILES[$fieldName] = [ + 'name' => $fileName, + 'type' => $type, + 'tmp_name' => $tmpName, + 'size' => $size, + 'error' => null + ]; + } + + /** + * Remove traces of previous fake uploads + */ + function clearFakeUploads() { + $_FILES = []; + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php b/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php new file mode 100644 index 00000000..bb720211 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php @@ -0,0 +1,44 @@ +<?php + +/** + * @covers ApiUsageException + */ +class ApiUsageExceptionTest extends MediaWikiTestCase { + + public function testCreateWithStatusValue_CanGetAMessageObject() { + $messageKey = 'some-message-key'; + $messageParameter = 'some-parameter'; + $statusValue = new StatusValue(); + $statusValue->fatal( $messageKey, $messageParameter ); + + $apiUsageException = new ApiUsageException( null, $statusValue ); + /** @var \Message $gotMessage */ + $gotMessage = $apiUsageException->getMessageObject(); + + $this->assertInstanceOf( \Message::class, $gotMessage ); + $this->assertEquals( $messageKey, $gotMessage->getKey() ); + $this->assertEquals( [ $messageParameter ], $gotMessage->getParams() ); + } + + public function testNewWithMessage_ThenGetMessageObject_ReturnsApiMessageWithProvidedData() { + $expectedMessage = new Message( 'some-message-key', [ 'some message parameter' ] ); + $expectedCode = 'some-error-code'; + $expectedData = [ 'some-error-data' ]; + + $apiUsageException = ApiUsageException::newWithMessage( + null, + $expectedMessage, + $expectedCode, + $expectedData + ); + /** @var \ApiMessage $gotMessage */ + $gotMessage = $apiUsageException->getMessageObject(); + + $this->assertInstanceOf( \ApiMessage::class, $gotMessage ); + $this->assertEquals( $expectedMessage->getKey(), $gotMessage->getKey() ); + $this->assertEquals( $expectedMessage->getParams(), $gotMessage->getParams() ); + $this->assertEquals( $expectedCode, $gotMessage->getApiCode() ); + $this->assertEquals( $expectedData, $gotMessage->getApiData() ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php b/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php new file mode 100644 index 00000000..0229e767 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php @@ -0,0 +1,358 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * + * @covers ApiUserrights + */ +class ApiUserrightsTest extends ApiTestCase { + /** + * Unsets $wgGroupPermissions['bureaucrat']['userrights'], and sets + * $wgAddGroups['bureaucrat'] and $wgRemoveGroups['bureaucrat'] to the + * specified values. + * + * @param array|bool $add Groups bureaucrats should be allowed to add, true for all + * @param array|bool $remove Groups bureaucrats should be allowed to remove, true for all + */ + protected function setPermissions( $add = [], $remove = [] ) { + global $wgAddGroups, $wgRemoveGroups; + + $this->setGroupPermissions( 'bureaucrat', 'userrights', false ); + + if ( $add ) { + $this->stashMwGlobals( 'wgAddGroups' ); + $wgAddGroups['bureaucrat'] = $add; + } + if ( $remove ) { + $this->stashMwGlobals( 'wgRemoveGroups' ); + $wgRemoveGroups['bureaucrat'] = $remove; + } + } + + /** + * Perform an API userrights request that's expected to be successful. + * + * @param array|string $expectedGroups Group(s) that the user is expected + * to have after the API request + * @param array $params Array to pass to doApiRequestWithToken(). 'action' + * => 'userrights' is implicit. If no 'user' or 'userid' is specified, + * we add a 'user' parameter. If no 'add' or 'remove' is specified, we + * add 'add' => 'sysop'. + * @param User|null $user The user that we're modifying. The user must be + * mutable, because we're going to change its groups! null means that + * we'll make up our own user to modify, and doesn't make sense if 'user' + * or 'userid' is specified in $params. + */ + protected function doSuccessfulRightsChange( + $expectedGroups = 'sysop', array $params = [], User $user = null + ) { + $expectedGroups = (array)$expectedGroups; + $params['action'] = 'userrights'; + + if ( !$user ) { + $user = $this->getMutableTestUser()->getUser(); + } + + $this->assertTrue( TestUserRegistry::isMutable( $user ), + 'Immutable user passed to doSuccessfulRightsChange!' ); + + if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) { + $params['user'] = $user->getName(); + } + if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) { + $params['add'] = 'sysop'; + } + + $res = $this->doApiRequestWithToken( $params ); + + $user->clearInstanceCache(); + $this->assertSame( $expectedGroups, $user->getGroups() ); + + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + /** + * Perform an API userrights request that's expected to fail. + * + * @param string $expectedException Expected exception text + * @param array $params As for doSuccessfulRightsChange() + * @param User|null $user As for doSuccessfulRightsChange(). If there's no + * user who will possibly be affected (such as if an invalid username is + * provided in $params), pass null. + */ + protected function doFailedRightsChange( + $expectedException, array $params = [], User $user = null + ) { + $params['action'] = 'userrights'; + + $this->setExpectedException( ApiUsageException::class, $expectedException ); + + if ( !$user ) { + // If 'user' or 'userid' is specified and $user was not specified, + // the user we're creating now will have nothing to do with the API + // request, but that's okay, since we're just testing that it has + // no groups. + $user = $this->getMutableTestUser()->getUser(); + } + + $this->assertTrue( TestUserRegistry::isMutable( $user ), + 'Immutable user passed to doFailedRightsChange!' ); + + if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) { + $params['user'] = $user->getName(); + } + if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) { + $params['add'] = 'sysop'; + } + $expectedGroups = $user->getGroups(); + + try { + $this->doApiRequestWithToken( $params ); + } finally { + $user->clearInstanceCache(); + $this->assertSame( $expectedGroups, $user->getGroups() ); + } + } + + public function testAdd() { + $this->doSuccessfulRightsChange(); + } + + public function testBlockedWithUserrights() { + global $wgUser; + + $block = new Block( [ 'address' => $wgUser, 'by' => $wgUser->getId(), ] ); + $block->insert(); + + try { + $this->doSuccessfulRightsChange(); + } finally { + $block->delete(); + $wgUser->clearInstanceCache(); + } + } + + public function testBlockedWithoutUserrights() { + $user = $this->getTestSysop()->getUser(); + + $this->setPermissions( true, true ); + + $block = new Block( [ 'address' => $user, 'by' => $user->getId() ] ); + $block->insert(); + + try { + $this->doFailedRightsChange( 'You have been blocked from editing.' ); + } finally { + $block->delete(); + $user->clearInstanceCache(); + } + } + + public function testAddMultiple() { + $this->doSuccessfulRightsChange( + [ 'bureaucrat', 'sysop' ], + [ 'add' => 'bureaucrat|sysop' ] + ); + } + + public function testTooFewExpiries() { + $this->doFailedRightsChange( + '2 expiry timestamps were provided where 3 were needed.', + [ 'add' => 'sysop|bureaucrat|bot', 'expiry' => 'infinity|tomorrow' ] + ); + } + + public function testTooManyExpiries() { + $this->doFailedRightsChange( + '3 expiry timestamps were provided where 2 were needed.', + [ 'add' => 'sysop|bureaucrat', 'expiry' => 'infinity|tomorrow|never' ] + ); + } + + public function testInvalidExpiry() { + $this->doFailedRightsChange( 'Invalid expiry time', [ 'expiry' => 'yummy lollipops!' ] ); + } + + public function testMultipleInvalidExpiries() { + $this->doFailedRightsChange( + 'Invalid expiry time "foo".', + [ 'add' => 'sysop|bureaucrat', 'expiry' => 'foo|bar' ] + ); + } + + public function testWithTag() { + ChangeTags::defineTag( 'custom tag' ); + + $user = $this->getMutableTestUser()->getUser(); + + $this->doSuccessfulRightsChange( 'sysop', [ 'tags' => 'custom tag' ], $user ); + + $dbr = wfGetDB( DB_REPLICA ); + $this->assertSame( + 'custom tag', + $dbr->selectField( + [ 'change_tag', 'logging' ], + 'ct_tag', + [ + 'ct_log_id = log_id', + 'log_namespace' => NS_USER, + 'log_title' => strtr( $user->getName(), ' ', '_' ) + ], + __METHOD__ + ) + ); + } + + public function testWithoutTagPermission() { + global $wgGroupPermissions; + + ChangeTags::defineTag( 'custom tag' ); + + $this->stashMwGlobals( 'wgGroupPermissions' ); + $wgGroupPermissions['user']['applychangetags'] = false; + + $this->doFailedRightsChange( + 'You do not have permission to apply change tags along with your changes.', + [ 'tags' => 'custom tag' ] + ); + } + + public function testNonexistentUser() { + $this->doFailedRightsChange( + 'There is no user by the name "Nonexistent user". Check your spelling.', + [ 'user' => 'Nonexistent user' ] + ); + } + + public function testWebToken() { + $sysop = $this->getTestSysop()->getUser(); + $user = $this->getMutableTestUser()->getUser(); + + $token = $sysop->getEditToken( $user->getName() ); + + $res = $this->doApiRequest( [ + 'action' => 'userrights', + 'user' => $user->getName(), + 'add' => 'sysop', + 'token' => $token, + ] ); + + $user->clearInstanceCache(); + $this->assertSame( [ 'sysop' ], $user->getGroups() ); + + $this->assertArrayNotHasKey( 'warnings', $res[0] ); + } + + /** + * Helper for testCanProcessExpiries that returns a mock ApiUserrights that either can or cannot + * process expiries. Although the regular page can process expiries, we use a mock here to + * ensure that it's the result of canProcessExpiries() that makes a difference, and not some + * error in the way we construct the mock. + * + * @param bool $canProcessExpiries + */ + private function getMockForProcessingExpiries( $canProcessExpiries ) { + $sysop = $this->getTestSysop()->getUser(); + $user = $this->getMutableTestUser()->getUser(); + + $token = $sysop->getEditToken( 'userrights' ); + + $main = new ApiMain( new FauxRequest( [ + 'action' => 'userrights', + 'user' => $user->getName(), + 'add' => 'sysop', + 'token' => $token, + ] ) ); + + $mockUserRightsPage = $this->getMockBuilder( UserrightsPage::class ) + ->setMethods( [ 'canProcessExpiries' ] ) + ->getMock(); + $mockUserRightsPage->method( 'canProcessExpiries' )->willReturn( $canProcessExpiries ); + + $mockApi = $this->getMockBuilder( ApiUserrights::class ) + ->setConstructorArgs( [ $main, 'userrights' ] ) + ->setMethods( [ 'getUserRightsPage' ] ) + ->getMock(); + $mockApi->method( 'getUserRightsPage' )->willReturn( $mockUserRightsPage ); + + return $mockApi; + } + + public function testCanProcessExpiries() { + $mock1 = $this->getMockForProcessingExpiries( true ); + $this->assertArrayHasKey( 'expiry', $mock1->getAllowedParams() ); + + $mock2 = $this->getMockForProcessingExpiries( false ); + $this->assertArrayNotHasKey( 'expiry', $mock2->getAllowedParams() ); + } + + /** + * Tests adding and removing various groups with various permissions. + * + * @dataProvider addAndRemoveGroupsProvider + * @param array|null $permissions [ [ $wgAddGroups, $wgRemoveGroups ] ] or null for 'userrights' + * to be set in $wgGroupPermissions + * @param array $groupsToChange [ [ groups to add ], [ groups to remove ] ] + * @param array $expectedGroups Array of expected groups + */ + public function testAddAndRemoveGroups( + array $permissions = null, array $groupsToChange, array $expectedGroups + ) { + if ( $permissions !== null ) { + $this->setPermissions( $permissions[0], $permissions[1] ); + } + + $params = [ + 'add' => implode( '|', $groupsToChange[0] ), + 'remove' => implode( '|', $groupsToChange[1] ), + ]; + + // We'll take a bot so we have a group to remove + $user = $this->getMutableTestUser( [ 'bot' ] )->getUser(); + + $this->doSuccessfulRightsChange( $expectedGroups, $params, $user ); + } + + public function addAndRemoveGroupsProvider() { + return [ + 'Simple add' => [ + [ [ 'sysop' ], [] ], + [ [ 'sysop' ], [] ], + [ 'bot', 'sysop' ] + ], 'Add with only remove permission' => [ + [ [], [ 'sysop' ] ], + [ [ 'sysop' ], [] ], + [ 'bot' ], + ], 'Add with global remove permission' => [ + [ [], true ], + [ [ 'sysop' ], [] ], + [ 'bot' ], + ], 'Simple remove' => [ + [ [], [ 'bot' ] ], + [ [], [ 'bot' ] ], + [], + ], 'Remove with only add permission' => [ + [ [ 'bot' ], [] ], + [ [], [ 'bot' ] ], + [ 'bot' ], + ], 'Remove with global add permission' => [ + [ true, [] ], + [ [], [ 'bot' ] ], + [ 'bot' ], + ], 'Add and remove same new group' => [ + null, + [ [ 'sysop' ], [ 'sysop' ] ], + // The userrights code does removals before adds, so it doesn't remove the sysop + // group here and only adds it. + [ 'bot', 'sysop' ], + ], 'Add and remove same existing group' => [ + null, + [ [ 'bot' ], [ 'bot' ] ], + // But here it first removes the existing group and then re-adds it. + [ 'bot' ], + ], + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php b/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php new file mode 100644 index 00000000..6d64a178 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php @@ -0,0 +1,148 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @todo This test suite is severly broken and need a full review + * + * @covers ApiWatch + */ +class ApiWatchTest extends ApiTestCase { + function getTokens() { + return $this->getTokenList( self::$users['sysop'] ); + } + + public function testWatchEdit() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( [ + 'action' => 'edit', + 'title' => 'Help:UTPage', // Help namespace is hopefully wikitext + 'text' => 'new text', + 'token' => $tokens['edittoken'], + 'watchlist' => 'watch' ] ); + $this->assertArrayHasKey( 'edit', $data[0] ); + $this->assertArrayHasKey( 'result', $data[0]['edit'] ); + $this->assertEquals( 'Success', $data[0]['edit']['result'] ); + + return $data; + } + + /** + * @depends testWatchEdit + */ + public function testWatchClear() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( [ + 'action' => 'query', + 'wllimit' => 'max', + 'list' => 'watchlist' ] ); + + if ( isset( $data[0]['query']['watchlist'] ) ) { + $wl = $data[0]['query']['watchlist']; + + foreach ( $wl as $page ) { + $data = $this->doApiRequest( [ + 'action' => 'watch', + 'title' => $page['title'], + 'unwatch' => true, + 'token' => $tokens['watchtoken'] ] ); + } + } + $data = $this->doApiRequest( [ + 'action' => 'query', + 'list' => 'watchlist' ], $data ); + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'watchlist', $data[0]['query'] ); + foreach ( $data[0]['query']['watchlist'] as $index => $item ) { + // Previous tests may insert an invalid title + // like ":ApiEditPageTest testNonTextEdit", which + // can't be cleared. + if ( strpos( $item['title'], ':' ) === 0 ) { + unset( $data[0]['query']['watchlist'][$index] ); + } + } + $this->assertEquals( 0, count( $data[0]['query']['watchlist'] ) ); + + return $data; + } + + public function testWatchProtect() { + $tokens = $this->getTokens(); + + $data = $this->doApiRequest( [ + 'action' => 'protect', + 'token' => $tokens['protecttoken'], + 'title' => 'Help:UTPage', + 'protections' => 'edit=sysop', + 'watchlist' => 'unwatch' ] ); + + $this->assertArrayHasKey( 'protect', $data[0] ); + $this->assertArrayHasKey( 'protections', $data[0]['protect'] ); + $this->assertEquals( 1, count( $data[0]['protect']['protections'] ) ); + $this->assertArrayHasKey( 'edit', $data[0]['protect']['protections'][0] ); + } + + public function testGetRollbackToken() { + $this->getTokens(); + + if ( !Title::newFromText( 'Help:UTPage' )->exists() ) { + $this->markTestSkipped( "The article [[Help:UTPage]] does not exist" ); // TODO: just create it? + } + + $data = $this->doApiRequest( [ + 'action' => 'query', + 'prop' => 'revisions', + 'titles' => 'Help:UTPage', + 'rvtoken' => 'rollback' ] ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'pages', $data[0]['query'] ); + $keys = array_keys( $data[0]['query']['pages'] ); + $key = array_pop( $keys ); + + if ( isset( $data[0]['query']['pages'][$key]['missing'] ) ) { + $this->markTestSkipped( "Target page (Help:UTPage) doesn't exist" ); + } + + $this->assertArrayHasKey( 'pageid', $data[0]['query']['pages'][$key] ); + $this->assertArrayHasKey( 'revisions', $data[0]['query']['pages'][$key] ); + $this->assertArrayHasKey( 0, $data[0]['query']['pages'][$key]['revisions'] ); + $this->assertArrayHasKey( 'rollbacktoken', $data[0]['query']['pages'][$key]['revisions'][0] ); + + return $data; + } + + /** + * @group Broken + * Broken because there is currently no revision info in the $pageinfo + * + * @depends testGetRollbackToken + */ + public function testWatchRollback( $data ) { + $keys = array_keys( $data[0]['query']['pages'] ); + $key = array_pop( $keys ); + $pageinfo = $data[0]['query']['pages'][$key]; + $revinfo = $pageinfo['revisions'][0]; + + try { + $data = $this->doApiRequest( [ + 'action' => 'rollback', + 'title' => 'Help:UTPage', + 'user' => $revinfo['user'], + 'token' => $pageinfo['rollbacktoken'], + 'watchlist' => 'watch' ] ); + + $this->assertArrayHasKey( 'rollback', $data[0] ); + $this->assertArrayHasKey( 'title', $data[0]['rollback'] ); + } catch ( ApiUsageException $ue ) { + if ( self::apiExceptionHasCode( $ue, 'onlyauthor' ) ) { + $this->markTestIncomplete( "Only one author to 'Help:UTPage', cannot test rollback" ); + } else { + $this->fail( "Received error '" . $ue->getMessage() . "'" ); + } + } + } +} diff --git a/www/wiki/tests/phpunit/includes/api/MockApi.php b/www/wiki/tests/phpunit/includes/api/MockApi.php new file mode 100644 index 00000000..1407c10d --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/MockApi.php @@ -0,0 +1,27 @@ +<?php + +class MockApi extends ApiBase { + public $warnings = []; + + public function execute() { + } + + public function __construct() { + } + + public function getModulePath() { + return $this->getModuleName(); + } + + public function addWarning( $warning, $code = null, $data = null ) { + $this->warnings[] = $warning; + } + + public function getAllowedParams() { + return [ + 'filename' => null, + 'enablechunks' => false, + 'sessionkey' => null, + ]; + } +} diff --git a/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php b/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php new file mode 100644 index 00000000..9915a38d --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php @@ -0,0 +1,19 @@ +<?php +class MockApiQueryBase extends ApiQueryBase { + private $name; + + public function execute() { + } + + public function __construct( $name = 'mock' ) { + $this->name = $name; + } + + public function getModuleName() { + return $this->name; + } + + public function getModulePath() { + return 'query+' . $this->getModuleName(); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php b/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php new file mode 100644 index 00000000..d125a7d5 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php @@ -0,0 +1,30 @@ +<?php + +/** + * Checks that all API query modules, core and extensions, have unique prefixes. + * + * @group API + */ +class PrefixUniquenessTest extends MediaWikiTestCase { + + public function testPrefixes() { + $main = new ApiMain( new FauxRequest() ); + $query = new ApiQuery( $main, 'foo', 'bar' ); + $moduleManager = $query->getModuleManager(); + + $modules = $moduleManager->getNames(); + $prefixes = []; + + foreach ( $modules as $name ) { + $module = $moduleManager->getModule( $name ); + $class = get_class( $module ); + + $prefix = $module->getModulePrefix(); + if ( $prefix !== '' && isset( $prefixes[$prefix] ) ) { + $this->fail( "Module prefix '{$prefix}' is shared between {$class} and {$prefixes[$prefix]}" ); + } + $prefixes[$module->getModulePrefix()] = $class; + } + $this->assertTrue( true ); // dummy call to make this test non-incomplete + } +} diff --git a/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php b/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php new file mode 100644 index 00000000..50a59f97 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php @@ -0,0 +1,497 @@ +<?php +/** + * RandomImageGenerator -- does what it says on the tin. + * Requires Imagick, the ImageMagick library for PHP, or the command line + * equivalent (usually 'convert'). + * + * Because MediaWiki tests the uniqueness of media upload content, and + * filenames, it is sometimes useful to generate files that are guaranteed (or + * at least very likely) to be unique in both those ways. This generates a + * number of filenames with random names and random content (colored triangles). + * + * It is also useful to have fresh content because our tests currently run in a + * "destructive" mode, and don't create a fresh new wiki for each test run. + * Consequently, if we just had a few static files we kept re-uploading, we'd + * get lots of warnings about matching content or filenames, and even if we + * deleted those files, we'd get warnings about archived files. + * + * This can also be used with a cronjob to generate random files all the time. + * I use it to have a constant, never ending supply when I'm testing + * interactively. + * + * @file + * @author Neil Kandalgaonkar <neilk@wikimedia.org> + */ + +/** + * RandomImageGenerator: does what it says on the tin. + * Can fetch a random image, or also write a number of them to disk with random filenames. + */ +class RandomImageGenerator { + private $dictionaryFile; + private $minWidth = 400; + private $maxWidth = 800; + private $minHeight = 400; + private $maxHeight = 800; + private $shapesToDraw = 5; + + /** + * Orientations: 0th row, 0th column, Exif orientation code, rotation 2x2 + * matrix that is opposite of orientation. N.b. we do not handle the + * 'flipped' orientations, which is why there is no entry for 2, 4, 5, or 7. + * Those seem to be rare in real images anyway (we also would need a + * non-symmetric shape for the images to test those, like a letter F). + */ + private static $orientations = [ + [ + '0thRow' => 'top', + '0thCol' => 'left', + 'exifCode' => 1, + 'counterRotation' => [ [ 1, 0 ], [ 0, 1 ] ] + ], + [ + '0thRow' => 'bottom', + '0thCol' => 'right', + 'exifCode' => 3, + 'counterRotation' => [ [ -1, 0 ], [ 0, -1 ] ] + ], + [ + '0thRow' => 'right', + '0thCol' => 'top', + 'exifCode' => 6, + 'counterRotation' => [ [ 0, 1 ], [ 1, 0 ] ] + ], + [ + '0thRow' => 'left', + '0thCol' => 'bottom', + 'exifCode' => 8, + 'counterRotation' => [ [ 0, -1 ], [ -1, 0 ] ] + ] + ]; + + public function __construct( $options = [] ) { + foreach ( [ 'dictionaryFile', 'minWidth', 'minHeight', + 'maxWidth', 'maxHeight', 'shapesToDraw' ] as $property + ) { + if ( isset( $options[$property] ) ) { + $this->$property = $options[$property]; + } + } + + // find the dictionary file, to generate random names + if ( !isset( $this->dictionaryFile ) ) { + foreach ( + [ + '/usr/share/dict/words', + '/usr/dict/words', + __DIR__ . '/words.txt' + ] as $dictionaryFile + ) { + if ( is_file( $dictionaryFile ) && is_readable( $dictionaryFile ) ) { + $this->dictionaryFile = $dictionaryFile; + break; + } + } + } + if ( !isset( $this->dictionaryFile ) ) { + throw new Exception( "RandomImageGenerator: dictionary file not " + . "found or not specified properly" ); + } + } + + /** + * Writes random images with random filenames to disk in the directory you + * specify, or current working directory. + * + * @param int $number Number of filenames to write + * @param string $format Optional, must be understood by ImageMagick, such as 'jpg' or 'gif' + * @param string $dir Directory, optional (will default to current working directory) + * @return array Filenames we just wrote + */ + function writeImages( $number, $format = 'jpg', $dir = null ) { + $filenames = $this->getRandomFilenames( $number, $format, $dir ); + $imageWriteMethod = $this->getImageWriteMethod( $format ); + foreach ( $filenames as $filename ) { + $this->{$imageWriteMethod}( $this->getImageSpec(), $format, $filename ); + } + + return $filenames; + } + + /** + * Figure out how we write images. This is a factor of both format and the local system + * + * @param string $format (a typical extension like 'svg', 'jpg', etc.) + * + * @throws Exception + * @return string + */ + function getImageWriteMethod( $format ) { + global $wgUseImageMagick, $wgImageMagickConvertCommand; + if ( $format === 'svg' ) { + return 'writeSvg'; + } else { + // figure out how to write images + global $wgExiv2Command; + if ( class_exists( 'Imagick' ) && $wgExiv2Command && is_executable( $wgExiv2Command ) ) { + return 'writeImageWithApi'; + } elseif ( $wgUseImageMagick + && $wgImageMagickConvertCommand + && is_executable( $wgImageMagickConvertCommand ) + ) { + return 'writeImageWithCommandLine'; + } + } + throw new Exception( "RandomImageGenerator: could not find a suitable " + . "method to write images in '$format' format" ); + } + + /** + * Return a number of randomly-generated filenames + * Each filename uses two words randomly drawn from the dictionary, like elephantine_spatula.jpg + * + * @param int $number Number of filenames to generate + * @param string $extension Optional, defaults to 'jpg' + * @param string $dir Optional, defaults to current working directory + * @return array Array of filenames + */ + private function getRandomFilenames( $number, $extension = 'jpg', $dir = null ) { + if ( is_null( $dir ) ) { + $dir = getcwd(); + } + $filenames = []; + foreach ( $this->getRandomWordPairs( $number ) as $pair ) { + $basename = $pair[0] . '_' . $pair[1]; + if ( !is_null( $extension ) ) { + $basename .= '.' . $extension; + } + $basename = preg_replace( '/\s+/', '', $basename ); + $filenames[] = "$dir/$basename"; + } + + return $filenames; + } + + /** + * Generate data representing an image of random size (within limits), + * consisting of randomly colored and sized upward pointing triangles + * against a random background color. (This data is used in the + * writeImage* methods). + * + * @return mixed + */ + public function getImageSpec() { + $spec = []; + + $spec['width'] = mt_rand( $this->minWidth, $this->maxWidth ); + $spec['height'] = mt_rand( $this->minHeight, $this->maxHeight ); + $spec['fill'] = $this->getRandomColor(); + + $diagonalLength = sqrt( pow( $spec['width'], 2 ) + pow( $spec['height'], 2 ) ); + + $draws = []; + for ( $i = 0; $i <= $this->shapesToDraw; $i++ ) { + $radius = mt_rand( 0, $diagonalLength / 4 ); + if ( $radius == 0 ) { + continue; + } + $originX = mt_rand( -1 * $radius, $spec['width'] + $radius ); + $originY = mt_rand( -1 * $radius, $spec['height'] + $radius ); + $angle = mt_rand( 0, ( 3.141592 / 2 ) * $radius ) / $radius; + $legDeltaX = round( $radius * sin( $angle ) ); + $legDeltaY = round( $radius * cos( $angle ) ); + + $draw = []; + $draw['fill'] = $this->getRandomColor(); + $draw['shape'] = [ + [ 'x' => $originX, 'y' => $originY - $radius ], + [ 'x' => $originX + $legDeltaX, 'y' => $originY + $legDeltaY ], + [ 'x' => $originX - $legDeltaX, 'y' => $originY + $legDeltaY ], + [ 'x' => $originX, 'y' => $originY - $radius ] + ]; + $draws[] = $draw; + } + + $spec['draws'] = $draws; + + return $spec; + } + + /** + * Given [ [ 'x' => 10, 'y' => 20 ], [ 'x' => 30, y=> 5 ] ] + * returns "10,20 30,5" + * Useful for SVG and imagemagick command line arguments + * @param array $shape Array of arrays, each array containing x & y keys mapped to numeric values + * @return string + */ + static function shapePointsToString( $shape ) { + $points = []; + foreach ( $shape as $point ) { + $points[] = $point['x'] . ',' . $point['y']; + } + + return implode( " ", $points ); + } + + /** + * Based on image specification, write a very simple SVG file to disk. + * Ignores the background spec because transparency is cool. :) + * + * @param array $spec Spec describing background and shapes to draw + * @param string $format File format to write (which is obviously always svg here) + * @param string $filename Filename to write to + * + * @throws Exception + */ + public function writeSvg( $spec, $format, $filename ) { + $svg = new SimpleXmlElement( '<svg/>' ); + $svg->addAttribute( 'xmlns', 'http://www.w3.org/2000/svg' ); + $svg->addAttribute( 'version', '1.1' ); + $svg->addAttribute( 'width', $spec['width'] ); + $svg->addAttribute( 'height', $spec['height'] ); + $g = $svg->addChild( 'g' ); + foreach ( $spec['draws'] as $drawSpec ) { + $shape = $g->addChild( 'polygon' ); + $shape->addAttribute( 'fill', $drawSpec['fill'] ); + $shape->addAttribute( 'points', self::shapePointsToString( $drawSpec['shape'] ) ); + } + + $fh = fopen( $filename, 'w' ); + if ( !$fh ) { + throw new Exception( "couldn't open $filename for writing" ); + } + fwrite( $fh, $svg->asXML() ); + if ( !fclose( $fh ) ) { + throw new Exception( "couldn't close $filename" ); + } + } + + /** + * Based on an image specification, write such an image to disk, using Imagick PHP extension + * @param array $spec Spec describing background and circles to draw + * @param string $format File format to write + * @param string $filename Filename to write to + */ + public function writeImageWithApi( $spec, $format, $filename ) { + // this is a hack because I can't get setImageOrientation() to work. See below. + global $wgExiv2Command; + + $image = new Imagick(); + /** + * If the format is 'jpg', will also add a random orientation -- the + * image will be drawn rotated with triangle points facing in some + * direction (0, 90, 180 or 270 degrees) and a countering rotation + * should turn the triangle points upward again. + */ + $orientation = self::$orientations[0]; // default is normal orientation + if ( $format == 'jpg' ) { + $orientation = self::$orientations[array_rand( self::$orientations )]; + $spec = self::rotateImageSpec( $spec, $orientation['counterRotation'] ); + } + + $image->newImage( $spec['width'], $spec['height'], new ImagickPixel( $spec['fill'] ) ); + + foreach ( $spec['draws'] as $drawSpec ) { + $draw = new ImagickDraw(); + $draw->setFillColor( $drawSpec['fill'] ); + $draw->polygon( $drawSpec['shape'] ); + $image->drawImage( $draw ); + } + + $image->setImageFormat( $format ); + + // this doesn't work, even though it's documented to do so... + // $image->setImageOrientation( $orientation['exifCode'] ); + + $image->writeImage( $filename ); + + // because the above setImageOrientation call doesn't work... nor can I + // get an external imagemagick binary to do this either... Hacking this + // for now (only works if you have exiv2 installed, a program to read + // and manipulate exif). + if ( $wgExiv2Command ) { + $cmd = wfEscapeShellArg( $wgExiv2Command ) + . " -M " + . wfEscapeShellArg( "set Exif.Image.Orientation " . $orientation['exifCode'] ) + . " " + . wfEscapeShellArg( $filename ); + + $retval = 0; + $err = wfShellExec( $cmd, $retval ); + if ( $retval !== 0 ) { + print "Error with $cmd: $retval, $err\n"; + } + } + } + + /** + * Given an image specification, produce rotated version + * This is used when simulating a rotated image capture with Exif orientation + * @param array $spec Returned by getImageSpec + * @param array $matrix 2x2 transformation matrix + * @return array Transformed Spec + */ + private static function rotateImageSpec( &$spec, $matrix ) { + $tSpec = []; + $dims = self::matrixMultiply2x2( $matrix, $spec['width'], $spec['height'] ); + $correctionX = 0; + $correctionY = 0; + if ( $dims['x'] < 0 ) { + $correctionX = abs( $dims['x'] ); + } + if ( $dims['y'] < 0 ) { + $correctionY = abs( $dims['y'] ); + } + $tSpec['width'] = abs( $dims['x'] ); + $tSpec['height'] = abs( $dims['y'] ); + $tSpec['fill'] = $spec['fill']; + $tSpec['draws'] = []; + foreach ( $spec['draws'] as $draw ) { + $tDraw = [ + 'fill' => $draw['fill'], + 'shape' => [] + ]; + foreach ( $draw['shape'] as $point ) { + $tPoint = self::matrixMultiply2x2( $matrix, $point['x'], $point['y'] ); + $tPoint['x'] += $correctionX; + $tPoint['y'] += $correctionY; + $tDraw['shape'][] = $tPoint; + } + $tSpec['draws'][] = $tDraw; + } + + return $tSpec; + } + + /** + * Given a matrix and a pair of images, return new position + * @param array $matrix 2x2 rotation matrix + * @param int $x The x-coordinate number + * @param int $y The y-coordinate number + * @return array Transformed with properties x, y + */ + private static function matrixMultiply2x2( $matrix, $x, $y ) { + return [ + 'x' => $x * $matrix[0][0] + $y * $matrix[0][1], + 'y' => $x * $matrix[1][0] + $y * $matrix[1][1] + ]; + } + + /** + * Based on an image specification, write such an image to disk, using the + * command line ImageMagick program ('convert'). + * + * Sample command line: + * $ convert -size 100x60 xc:rgb(90,87,45) \ + * -draw 'fill rgb(12,34,56) polygon 41,39 44,57 50,57 41,39' \ + * -draw 'fill rgb(99,123,231) circle 59,39 56,57' \ + * -draw 'fill rgb(240,12,32) circle 50,21 50,3' filename.png + * + * @param array $spec Spec describing background and shapes to draw + * @param string $format File format to write (unused by this method but + * kept so it has the same signature as writeImageWithApi). + * @param string $filename Filename to write to + * + * @return bool + */ + public function writeImageWithCommandLine( $spec, $format, $filename ) { + global $wgImageMagickConvertCommand; + $args = []; + $args[] = "-size " . wfEscapeShellArg( $spec['width'] . 'x' . $spec['height'] ); + $args[] = wfEscapeShellArg( "xc:" . $spec['fill'] ); + foreach ( $spec['draws'] as $draw ) { + $fill = $draw['fill']; + $polygon = self::shapePointsToString( $draw['shape'] ); + $drawCommand = "fill $fill polygon $polygon"; + $args[] = '-draw ' . wfEscapeShellArg( $drawCommand ); + } + $args[] = wfEscapeShellArg( $filename ); + + $command = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . implode( " ", $args ); + $retval = null; + wfShellExec( $command, $retval ); + + return ( $retval === 0 ); + } + + /** + * Generate a string of random colors for ImageMagick or SVG, like "rgb(12, 37, 98)" + * + * @return string + */ + public function getRandomColor() { + $components = []; + for ( $i = 0; $i <= 2; $i++ ) { + $components[] = mt_rand( 0, 255 ); + } + + return 'rgb(' . implode( ', ', $components ) . ')'; + } + + /** + * Get an array of random pairs of random words, like + * [ [ 'foo', 'bar' ], [ 'quux', 'baz' ] ]; + * + * @param int $number Number of pairs + * @return array Two-element arrays + */ + private function getRandomWordPairs( $number ) { + $lines = $this->getRandomLines( $number * 2 ); + // construct pairs of words + $pairs = []; + $count = count( $lines ); + for ( $i = 0; $i < $count; $i += 2 ) { + $pairs[] = [ $lines[$i], $lines[$i + 1] ]; + } + + return $pairs; + } + + /** + * Return N random lines from a file + * + * Will throw exception if the file could not be read or if it had fewer lines than requested. + * + * @param int $number_desired Number of lines desired + * + * @throws Exception + * @return array Array of exactly n elements, drawn randomly from lines the file + */ + private function getRandomLines( $number_desired ) { + $filepath = $this->dictionaryFile; + + // initialize array of lines + $lines = []; + for ( $i = 0; $i < $number_desired; $i++ ) { + $lines[] = null; + } + + /* + * This algorithm obtains N random lines from a file in one single pass. + * It does this by replacing elements of a fixed-size array of lines, + * less and less frequently as it reads the file. + */ + $fh = fopen( $filepath, "r" ); + if ( !$fh ) { + throw new Exception( "couldn't open $filepath" ); + } + $line_number = 0; + $max_index = $number_desired - 1; + while ( !feof( $fh ) ) { + $line = fgets( $fh ); + if ( $line !== false ) { + $line_number++; + $line = trim( $line ); + if ( mt_rand( 0, $line_number ) <= $max_index ) { + $lines[mt_rand( 0, $max_index )] = $line; + } + } + } + fclose( $fh ); + if ( $line_number < $number_desired ) { + throw new Exception( "not enough lines in $filepath" ); + } + + return $lines; + } +} diff --git a/www/wiki/tests/phpunit/includes/api/UserWrapper.php b/www/wiki/tests/phpunit/includes/api/UserWrapper.php new file mode 100644 index 00000000..9942a0f2 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/UserWrapper.php @@ -0,0 +1,25 @@ +<?php + +class UserWrapper { + public $userName; + public $password; + public $user; + + public function __construct( $userName, $password, $group = '' ) { + $this->userName = $userName; + $this->password = $password; + + $this->user = User::newFromName( $this->userName ); + if ( !$this->user->getId() ) { + $this->user = User::createNew( $this->userName, [ + "email" => "test@example.com", + "real_name" => "Test User" ] ); + } + TestUser::setPasswordForUser( $this->user, $this->password ); + + if ( $group !== '' ) { + $this->user->addGroup( $group ); + } + $this->user->saveSettings(); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php new file mode 100644 index 00000000..55f760f6 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php @@ -0,0 +1,388 @@ +<?php + +use Wikimedia\TestingAccessWrapper; + +/** + * @group API + * @covers ApiFormatBase + */ +class ApiFormatBaseTest extends ApiFormatTestBase { + + protected $printerName = 'mockbase'; + + public function getMockFormatter( ApiMain $main = null, $format, $methods = [] ) { + if ( $main === null ) { + $context = new RequestContext; + $context->setRequest( new FauxRequest( [], true ) ); + $main = new ApiMain( $context ); + } + + $mock = $this->getMockBuilder( ApiFormatBase::class ) + ->setConstructorArgs( [ $main, $format ] ) + ->setMethods( array_unique( array_merge( $methods, [ 'getMimeType', 'execute' ] ) ) ) + ->getMock(); + if ( !in_array( 'getMimeType', $methods, true ) ) { + $mock->method( 'getMimeType' )->willReturn( 'text/x-mock' ); + } + return $mock; + } + + protected function encodeData( array $params, array $data, $options = [] ) { + $options += [ + 'name' => 'mock', + 'class' => ApiFormatBase::class, + 'factory' => function ( ApiMain $main, $format ) use ( $options ) { + $mock = $this->getMockFormatter( $main, $format ); + $mock->expects( $this->once() )->method( 'execute' ) + ->willReturnCallback( function () use ( $mock ) { + $mock->printText( "Format {$mock->getFormat()}: " ); + $mock->printText( "<b>ok</b>" ); + } ); + + if ( isset( $options['status'] ) ) { + $mock->setHttpStatus( $options['status'] ); + } + + return $mock; + }, + 'returnPrinter' => true, + ]; + + $this->setMwGlobals( [ + 'wgApiFrameOptions' => 'DENY', + ] ); + + $ret = parent::encodeData( $params, $data, $options ); + $printer = TestingAccessWrapper::newFromObject( $ret['printer'] ); + $text = $ret['text']; + + if ( $options['name'] !== 'mockfm' ) { + $ct = 'text/x-mock'; + $file = 'api-result.mock'; + $status = isset( $options['status'] ) ? $options['status'] : null; + } elseif ( isset( $params['wrappedhtml'] ) ) { + $ct = 'text/mediawiki-api-prettyprint-wrapped'; + $file = 'api-result-wrapped.json'; + $status = null; + + // Replace varying field + $text = preg_replace( '/"time":\d+/', '"time":1234', $text ); + } else { + $ct = 'text/html'; + $file = 'api-result.html'; + $status = null; + + // Strip OutputPage-generated HTML + if ( preg_match( '!<pre class="api-pretty-content">.*</pre>!s', $text, $m ) ) { + $text = $m[0]; + } + } + + $response = $printer->getMain()->getRequest()->response(); + $this->assertSame( "$ct; charset=utf-8", strtolower( $response->getHeader( 'Content-Type' ) ) ); + $this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) ); + $this->assertSame( $file, $printer->getFilename() ); + $this->assertSame( "inline; filename=$file", $response->getHeader( 'Content-Disposition' ) ); + $this->assertSame( $status, $response->getStatusCode() ); + + return $text; + } + + public static function provideGeneralEncoding() { + return [ + 'normal' => [ + [], + "Format MOCK: <b>ok</b>", + [], + [ 'name' => 'mock' ] + ], + 'normal ignores wrappedhtml' => [ + [], + "Format MOCK: <b>ok</b>", + [ 'wrappedhtml' => 1 ], + [ 'name' => 'mock' ] + ], + 'HTML format' => [ + [], + '<pre class="api-pretty-content">Format MOCK: <b>ok</b></pre>', + [], + [ 'name' => 'mockfm' ] + ], + 'wrapped HTML format' => [ + [], + // phpcs:ignore Generic.Files.LineLength.TooLong + '{"status":200,"statustext":"OK","html":"<pre class=\"api-pretty-content\">Format MOCK: <b>ok</b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}', + [ 'wrappedhtml' => 1 ], + [ 'name' => 'mockfm' ] + ], + 'normal, with set status' => [ + [], + "Format MOCK: <b>ok</b>", + [], + [ 'name' => 'mock', 'status' => 400 ] + ], + 'HTML format, with set status' => [ + [], + '<pre class="api-pretty-content">Format MOCK: <b>ok</b></pre>', + [], + [ 'name' => 'mockfm', 'status' => 400 ] + ], + 'wrapped HTML format, with set status' => [ + [], + // phpcs:ignore Generic.Files.LineLength.TooLong + '{"status":400,"statustext":"Bad Request","html":"<pre class=\"api-pretty-content\">Format MOCK: <b>ok</b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}', + [ 'wrappedhtml' => 1 ], + [ 'name' => 'mockfm', 'status' => 400 ] + ], + 'wrapped HTML format, cross-domain-policy' => [ + [ 'continue' => '< CrOsS-DoMaIn-PoLiCy >' ], + // phpcs:ignore Generic.Files.LineLength.TooLong + '{"status":200,"statustext":"OK","html":"<pre class=\"api-pretty-content\">Format MOCK: <b>ok</b></pre>","modules":["mediawiki.apipretty"],"continue":"\u003C CrOsS-DoMaIn-PoLiCy \u003E","time":1234}', + [ 'wrappedhtml' => 1 ], + [ 'name' => 'mockfm' ] + ], + ]; + } + + /** + * @dataProvider provideFilenameEncoding + */ + public function testFilenameEncoding( $filename, $expect ) { + $ret = parent::encodeData( [], [], [ + 'name' => 'mock', + 'class' => ApiFormatBase::class, + 'factory' => function ( ApiMain $main, $format ) use ( $filename ) { + $mock = $this->getMockFormatter( $main, $format, [ 'getFilename' ] ); + $mock->method( 'getFilename' )->willReturn( $filename ); + return $mock; + }, + 'returnPrinter' => true, + ] ); + $response = $ret['printer']->getMain()->getRequest()->response(); + + $this->assertSame( "inline; $expect", $response->getHeader( 'Content-Disposition' ) ); + } + + public static function provideFilenameEncoding() { + return [ + 'something simple' => [ + 'foo.xyz', 'filename=foo.xyz' + ], + 'more complicated, but still simple' => [ + 'foo.!#$%&\'*+-^_`|~', 'filename=foo.!#$%&\'*+-^_`|~' + ], + 'Needs quoting' => [ + 'foo\\bar.xyz', 'filename="foo\\\\bar.xyz"' + ], + 'Needs quoting (2)' => [ + 'foo (bar).xyz', 'filename="foo (bar).xyz"' + ], + 'Needs quoting (3)' => [ + "foo\t\"b\x5car\"\0.xyz", "filename=\"foo\x5c\t\x5c\"b\x5c\x5car\x5c\"\x5c\0.xyz\"" + ], + 'Non-ASCII characters' => [ + 'fóo bár.🙌!', + "filename=\"f\xF3o b\xE1r.?!\"; filename*=UTF-8''f%C3%B3o%20b%C3%A1r.%F0%9F%99%8C!" + ] + ]; + } + + public function testBasics() { + $printer = $this->getMockFormatter( null, 'mock' ); + $this->assertTrue( $printer->canPrintErrors() ); + $this->assertSame( + 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Data_formats', + $printer->getHelpUrls() + ); + } + + public function testDisable() { + $this->setMwGlobals( [ + 'wgApiFrameOptions' => 'DENY', + ] ); + + $printer = $this->getMockFormatter( null, 'mock' ); + $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) { + $printer->printText( 'Foo' ); + } ); + $this->assertFalse( $printer->isDisabled() ); + $printer->disable(); + $this->assertTrue( $printer->isDisabled() ); + + $printer->setHttpStatus( 400 ); + $printer->initPrinter(); + $printer->execute(); + ob_start(); + $printer->closePrinter(); + $this->assertSame( '', ob_get_clean() ); + $response = $printer->getMain()->getRequest()->response(); + $this->assertNull( $response->getHeader( 'Content-Type' ) ); + $this->assertNull( $response->getHeader( 'X-Frame-Options' ) ); + $this->assertNull( $response->getHeader( 'Content-Disposition' ) ); + $this->assertNull( $response->getStatusCode() ); + } + + public function testNullMimeType() { + $this->setMwGlobals( [ + 'wgApiFrameOptions' => 'DENY', + ] ); + + $printer = $this->getMockFormatter( null, 'mock', [ 'getMimeType' ] ); + $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) { + $printer->printText( 'Foo' ); + } ); + $printer->method( 'getMimeType' )->willReturn( null ); + $this->assertNull( $printer->getMimeType(), 'sanity check' ); + + $printer->initPrinter(); + $printer->execute(); + ob_start(); + $printer->closePrinter(); + $this->assertSame( 'Foo', ob_get_clean() ); + $response = $printer->getMain()->getRequest()->response(); + $this->assertNull( $response->getHeader( 'Content-Type' ) ); + $this->assertNull( $response->getHeader( 'X-Frame-Options' ) ); + $this->assertNull( $response->getHeader( 'Content-Disposition' ) ); + + $printer = $this->getMockFormatter( null, 'mockfm', [ 'getMimeType' ] ); + $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) { + $printer->printText( 'Foo' ); + } ); + $printer->method( 'getMimeType' )->willReturn( null ); + $this->assertNull( $printer->getMimeType(), 'sanity check' ); + $this->assertTrue( $printer->getIsHtml(), 'sanity check' ); + + $printer->initPrinter(); + $printer->execute(); + ob_start(); + $printer->closePrinter(); + $this->assertSame( 'Foo', ob_get_clean() ); + $response = $printer->getMain()->getRequest()->response(); + $this->assertSame( + 'text/html; charset=utf-8', strtolower( $response->getHeader( 'Content-Type' ) ) + ); + $this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) ); + $this->assertSame( + 'inline; filename=api-result.html', $response->getHeader( 'Content-Disposition' ) + ); + } + + public function testApiFrameOptions() { + $this->setMwGlobals( [ 'wgApiFrameOptions' => 'DENY' ] ); + $printer = $this->getMockFormatter( null, 'mock' ); + $printer->initPrinter(); + $this->assertSame( + 'DENY', + $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' ) + ); + + $this->setMwGlobals( [ 'wgApiFrameOptions' => 'SAMEORIGIN' ] ); + $printer = $this->getMockFormatter( null, 'mock' ); + $printer->initPrinter(); + $this->assertSame( + 'SAMEORIGIN', + $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' ) + ); + + $this->setMwGlobals( [ 'wgApiFrameOptions' => false ] ); + $printer = $this->getMockFormatter( null, 'mock' ); + $printer->initPrinter(); + $this->assertNull( + $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' ) + ); + } + + public function testForceDefaultParams() { + $context = new RequestContext; + $context->setRequest( new FauxRequest( [ 'foo' => '1', 'bar' => '2', 'baz' => '3' ], true ) ); + $main = new ApiMain( $context ); + $allowedParams = [ + 'foo' => [], + 'bar' => [ ApiBase::PARAM_DFLT => 'bar?' ], + 'baz' => 'baz!', + ]; + + $printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] ); + $printer->method( 'getAllowedParams' )->willReturn( $allowedParams ); + $this->assertEquals( + [ 'foo' => '1', 'bar' => '2', 'baz' => '3' ], + $printer->extractRequestParams(), + 'sanity check' + ); + + $printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] ); + $printer->method( 'getAllowedParams' )->willReturn( $allowedParams ); + $printer->forceDefaultParams(); + $this->assertEquals( + [ 'foo' => null, 'bar' => 'bar?', 'baz' => 'baz!' ], + $printer->extractRequestParams() + ); + } + + public function testGetAllowedParams() { + $printer = $this->getMockFormatter( null, 'mock' ); + $this->assertSame( [], $printer->getAllowedParams() ); + + $printer = $this->getMockFormatter( null, 'mockfm' ); + $this->assertSame( [ + 'wrappedhtml' => [ + ApiBase::PARAM_DFLT => false, + ApiBase::PARAM_HELP_MSG => 'apihelp-format-param-wrappedhtml', + ] + ], $printer->getAllowedParams() ); + } + + public function testGetExamplesMessages() { + $printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mock' ) ); + $this->assertSame( [ + 'action=query&meta=siteinfo&siprop=namespaces&format=mock' + => [ 'apihelp-format-example-generic', 'MOCK' ] + ], $printer->getExamplesMessages() ); + + $printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mockfm' ) ); + $this->assertSame( [ + 'action=query&meta=siteinfo&siprop=namespaces&format=mockfm' + => [ 'apihelp-format-example-generic', 'MOCK' ] + ], $printer->getExamplesMessages() ); + } + + /** + * @dataProvider provideHtmlHeader + */ + public function testHtmlHeader( $post, $registerNonHtml, $expect ) { + $context = new RequestContext; + $request = new FauxRequest( [ 'a' => 1, 'b' => 2 ], $post ); + $request->setRequestURL( 'http://example.org/wx/api.php' ); + $context->setRequest( $request ); + $context->setLanguage( 'qqx' ); + $main = new ApiMain( $context ); + $printer = $this->getMockFormatter( $main, 'mockfm' ); + $mm = $printer->getMain()->getModuleManager(); + $mm->addModule( 'mockfm', 'format', ApiFormatBase::class, function () { + return $mock; + } ); + if ( $registerNonHtml ) { + $mm->addModule( 'mock', 'format', ApiFormatBase::class, function () { + return $mock; + } ); + } + + $printer->initPrinter(); + $printer->execute(); + ob_start(); + $printer->closePrinter(); + $text = ob_get_clean(); + $this->assertContains( $expect, $text ); + } + + public static function provideHtmlHeader() { + return [ + [ false, false, '(api-format-prettyprint-header-only-html: MOCK)' ], + [ true, false, '(api-format-prettyprint-header-only-html: MOCK)' ], + // phpcs:ignore Generic.Files.LineLength.TooLong + [ false, true, '(api-format-prettyprint-header-hyperlinked: MOCK, mock, <a rel="nofollow" class="external free" href="http://example.org/wx/api.php?a=1&b=2&format=mock">http://example.org/wx/api.php?a=1&b=2&format=mock</a>)' ], + [ true, true, '(api-format-prettyprint-header: MOCK, mock)' ], + ]; + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php new file mode 100644 index 00000000..7eb2a35e --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php @@ -0,0 +1,129 @@ +<?php + +/** + * @group API + * @covers ApiFormatJson + */ +class ApiFormatJsonTest extends ApiFormatTestBase { + + protected $printerName = 'json'; + + private static function addFormatVersion( $format, $arr ) { + foreach ( $arr as &$p ) { + if ( !isset( $p[2] ) ) { + $p[2] = [ 'formatversion' => $format ]; + } else { + $p[2]['formatversion'] = $format; + } + } + return $arr; + } + + public static function provideGeneralEncoding() { + return array_merge( + self::addFormatVersion( 1, [ + // Basic types + [ [ null ], '[null]' ], + [ [ true ], '[""]' ], + [ [ false ], '[]' ], + [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ], + [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ], + [ [ 42 ], '[42]' ], + [ [ 42.5 ], '[42.5]' ], + [ [ 1e42 ], '[1.0e+42]' ], + [ [ 'foo' ], '["foo"]' ], + [ [ 'fóo' ], '["f\u00f3o"]' ], + [ [ 'fóo' ], '["fóo"]', [ 'utf8' => 1 ] ], + + // Arrays and objects + [ [ [] ], '[[]]' ], + [ [ [ 1 ] ], '[[1]]' ], + [ [ [ 'x' => 1 ] ], '[{"x":1}]' ], + [ [ [ 2 => 1 ] ], '[{"2":1}]' ], + [ [ (object)[] ], '[{}]' ], + [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ], + [ + [ [ + 'x' => 1, + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key' + ] ], + '[[{"key":"x","*":1}]]' + ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[{"x":1}]' ], + [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '[["a","b"]]' ], + + // Content + [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ], + '{"*":"foo"}' ], + + // BC Subelements + [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], + '{"foo":{"*":"foo"}}' ], + + // Callbacks + [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ], + + // Cross-domain mangling + [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ], + ] ), + self::addFormatVersion( 2, [ + // Basic types + [ [ null ], '[null]' ], + [ [ true ], '[true]' ], + [ [ false ], '[false]' ], + [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ], + [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ], + [ [ 42 ], '[42]' ], + [ [ 42.5 ], '[42.5]' ], + [ [ 1e42 ], '[1.0e+42]' ], + [ [ 'foo' ], '["foo"]' ], + [ [ 'fóo' ], '["fóo"]' ], + [ [ 'fóo' ], '["f\u00f3o"]', [ 'ascii' => 1 ] ], + + // Arrays and objects + [ [ [] ], '[[]]' ], + [ [ [ 'x' => 1 ] ], '[{"x":1}]' ], + [ [ [ 2 => 1 ] ], '[{"2":1}]' ], + [ [ (object)[] ], '[{}]' ], + [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ], + [ + [ [ + 'x' => 1, + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key' + ] ], + '[{"x":1}]' + ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[[1]]' ], + [ + [ [ + 'a', + 'b', + ApiResult::META_TYPE => 'BCassoc' + ] ], + '[{"0":"a","1":"b"}]' + ], + + // Content + [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ], + '{"content":"foo"}' ], + + // BC Subelements + [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], + '{"foo":"foo"}' ], + + // Callbacks + [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ], + + // Cross-domain mangling + [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ], + ] ) + ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php new file mode 100644 index 00000000..87e36703 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php @@ -0,0 +1,51 @@ +<?php + +/** + * @group API + * @covers ApiFormatNone + */ +class ApiFormatNoneTest extends ApiFormatTestBase { + + protected $printerName = 'none'; + + public static function provideGeneralEncoding() { + return [ + // Basic types + [ [ null ], '' ], + [ [ true ], '' ], + [ [ false ], '' ], + [ [ 42 ], '' ], + [ [ 42.5 ], '' ], + [ [ 1e42 ], '' ], + [ [ 'foo' ], '' ], + [ [ 'fóo' ], '' ], + + // Arrays and objects + [ [ [] ], '' ], + [ [ [ 1 ] ], '' ], + [ [ [ 'x' => 1 ] ], '' ], + [ [ [ 2 => 1 ] ], '' ], + [ [ (object)[] ], '' ], + [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '' ], + [ + [ [ + 'x' => 1, + ApiResult::META_TYPE => 'BCkvp', + ApiResult::META_KVP_KEY_NAME => 'key' + ] ], + '' + ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '' ], + [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '' ], + + // Content + [ [ '*' => 'foo' ], '' ], + + // BC Subelements + [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], '' ], + ]; + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php new file mode 100644 index 00000000..66e620e8 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php @@ -0,0 +1,139 @@ +<?php + +/** + * @group API + * @covers ApiFormatPhp + */ +class ApiFormatPhpTest extends ApiFormatTestBase { + + protected $printerName = 'php'; + + private static function addFormatVersion( $format, $arr ) { + foreach ( $arr as &$p ) { + if ( !isset( $p[2] ) ) { + $p[2] = [ 'formatversion' => $format ]; + } else { + $p[2]['formatversion'] = $format; + } + } + return $arr; + } + + public static function provideGeneralEncoding() { + // phpcs:disable Generic.Files.LineLength + return array_merge( + self::addFormatVersion( 1, [ + // Basic types + [ [ null ], 'a:1:{i:0;N;}' ], + [ [ true ], 'a:1:{i:0;s:0:"";}' ], + [ [ false ], 'a:0:{}' ], + [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], + 'a:1:{i:0;b:1;}' ], + [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], + 'a:1:{i:0;b:0;}' ], + [ [ 42 ], 'a:1:{i:0;i:42;}' ], + [ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ], + [ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ], + [ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ], + [ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ], + + // Arrays and objects + [ [ [] ], 'a:1:{i:0;a:0:{}}' ], + [ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ], + [ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ], + [ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ], + [ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ], + [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ], + 'a:1:{i:0;a:1:{i:0;a:2:{s:3:"key";s:1:"x";s:1:"*";i:1;}}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ], + [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ], + + // Content + [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ], + 'a:1:{s:1:"*";s:3:"foo";}' ], + + // BC Subelements + [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], + 'a:1:{s:3:"foo";a:1:{s:1:"*";s:3:"foo";}}' ], + ] ), + self::addFormatVersion( 2, [ + // Basic types + [ [ null ], 'a:1:{i:0;N;}' ], + [ [ true ], 'a:1:{i:0;b:1;}' ], + [ [ false ], 'a:1:{i:0;b:0;}' ], + [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], + 'a:1:{i:0;b:1;}' ], + [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], + 'a:1:{i:0;b:0;}' ], + [ [ 42 ], 'a:1:{i:0;i:42;}' ], + [ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ], + [ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ], + [ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ], + [ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ], + + // Arrays and objects + [ [ [] ], 'a:1:{i:0;a:0:{}}' ], + [ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ], + [ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ], + [ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ], + [ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ], + [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ], + 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ], + [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ], + + // Content + [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ], + 'a:1:{s:7:"content";s:3:"foo";}' ], + + // BC Subelements + [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], + 'a:1:{s:3:"foo";s:3:"foo";}' ], + ] ) + ); + // phpcs:enable + } + + public function testCrossDomainMangling() { + $config = new HashConfig( [ 'MangleFlashPolicy' => false ] ); + $context = new RequestContext; + $context->setConfig( new MultiConfig( [ + $config, + $context->getConfig(), + ] ) ); + $main = new ApiMain( $context ); + $main->getResult()->addValue( null, null, '< Cross-Domain-Policy >' ); + + $printer = $main->createPrinterByName( 'php' ); + ob_start( 'MediaWiki\\OutputHandler::handle' ); + $printer->initPrinter(); + $printer->execute(); + $printer->closePrinter(); + $ret = ob_get_clean(); + $this->assertSame( 'a:1:{i:0;s:23:"< Cross-Domain-Policy >";}', $ret ); + + $config->set( 'MangleFlashPolicy', true ); + $printer = $main->createPrinterByName( 'php' ); + ob_start( 'MediaWiki\\OutputHandler::handle' ); + try { + $printer->initPrinter(); + $printer->execute(); + $printer->closePrinter(); + ob_end_clean(); + $this->fail( 'Expected exception not thrown' ); + } catch ( ApiUsageException $ex ) { + ob_end_clean(); + $this->assertTrue( + $ex->getStatusValue()->hasMessage( 'apierror-formatphp' ), + 'Expected exception' + ); + } + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php new file mode 100644 index 00000000..f64af6d3 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php @@ -0,0 +1,120 @@ +<?php + +/** + * @group API + * @covers ApiFormatRaw + */ +class ApiFormatRawTest extends ApiFormatTestBase { + + protected $printerName = 'raw'; + + /** + * Test basic encoding and missing mime and text exceptions + * @return array datasets + */ + public static function provideGeneralEncoding() { + $options = [ + 'class' => ApiFormatRaw::class, + 'factory' => function ( ApiMain $main ) { + return new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) ); + } + ]; + + return [ + [ + [ 'mime' => 'text/plain', 'text' => 'foo' ], + 'foo', + [], + $options + ], + [ + [ 'mime' => 'text/plain', 'text' => 'fóo' ], + 'fóo', + [], + $options + ], + [ + [ 'text' => 'some text' ], + new MWException( 'No MIME type set for raw formatter' ), + [], + $options + ], + [ + [ 'mime' => 'text/plain' ], + new MWException( 'No text given for raw formatter' ), + [], + $options + ], + 'test error fallback' => [ + [ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ], + '{"mime":"text/plain","text":"some text","error":"some error"}', + [], + $options + ] + ]; + } + + /** + * Test specifying filename + */ + public function testFilename() { + $printer = new ApiFormatRaw( new ApiMain ); + $printer->getResult()->addValue( null, 'filename', 'whatever.raw' ); + $this->assertSame( 'whatever.raw', $printer->getFilename() ); + } + + /** + * Test specifying filename with error fallback printer + */ + public function testErrorFallbackFilename() { + $apiMain = new ApiMain; + $printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) ); + $printer->getResult()->addValue( null, 'error', 'some error' ); + $printer->getResult()->addValue( null, 'filename', 'whatever.raw' ); + $this->assertSame( 'api-result.json', $printer->getFilename() ); + } + + /** + * Test specifying mime + */ + public function testMime() { + $printer = new ApiFormatRaw( new ApiMain ); + $printer->getResult()->addValue( null, 'mime', 'text/plain' ); + $this->assertSame( 'text/plain', $printer->getMimeType() ); + } + + /** + * Test specifying mime with error fallback printer + */ + public function testErrorFallbackMime() { + $apiMain = new ApiMain; + $printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) ); + $printer->getResult()->addValue( null, 'error', 'some error' ); + $printer->getResult()->addValue( null, 'mime', 'text/plain' ); + $this->assertSame( 'application/json', $printer->getMimeType() ); + } + + /** + * Check that setting failWithHTTPError to true will result in 400 response status code + */ + public function testFailWithHTTPError() { + $apiMain = null; + + $this->testGeneralEncoding( + [ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ], + '{"mime":"text/plain","text":"some text","error":"some error"}', + [], + [ + 'class' => ApiFormatRaw::class, + 'factory' => function ( ApiMain $main ) use ( &$apiMain ) { + $apiMain = $main; + $printer = new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) ); + $printer->setFailWithHTTPError( true ); + return $printer; + } + ] + ); + $this->assertEquals( 400, $apiMain->getRequest()->response()->getStatusCode() ); + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php new file mode 100644 index 00000000..4169dab2 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php @@ -0,0 +1,93 @@ +<?php + +abstract class ApiFormatTestBase extends MediaWikiTestCase { + + /** + * Name of the formatter being tested + * @var string + */ + protected $printerName; + + /** + * Return general data to be encoded for testing + * @return array See self::testGeneralEncoding + * @throws BadMethodCallException + */ + public static function provideGeneralEncoding() { + throw new BadMethodCallException( static::class . ' must implement ' . __METHOD__ ); + } + + /** + * Get the formatter output for the given input data + * @param array $params Query parameters + * @param array $data Data to encode + * @param array $options Options. If passed a string, the string is treated + * as the 'class' option. + * - name: Format name, rather than $this->printerName + * - class: If set, register 'name' with this class (and 'factory', if that's set) + * - factory: Used with 'class' to register at runtime + * - returnPrinter: Return the printer object + * @param callable|null $factory Factory to use instead of the normal one + * @return string|array The string if $options['returnPrinter'] isn't set, or an array if it is: + * - text: Output text string + * - printer: ApiFormatBase + * @throws Exception + */ + protected function encodeData( array $params, array $data, $options = [] ) { + if ( is_string( $options ) ) { + $options = [ 'class' => $options ]; + } + $printerName = isset( $options['name'] ) ? $options['name'] : $this->printerName; + + $context = new RequestContext; + $context->setRequest( new FauxRequest( $params, true ) ); + $main = new ApiMain( $context ); + if ( isset( $options['class'] ) ) { + $factory = isset( $options['factory'] ) ? $options['factory'] : null; + $main->getModuleManager()->addModule( $printerName, 'format', $options['class'], $factory ); + } + $result = $main->getResult(); + $result->addArrayType( null, 'default' ); + foreach ( $data as $k => $v ) { + $result->addValue( null, $k, $v ); + } + + $ret = []; + $printer = $main->createPrinterByName( $printerName ); + $printer->initPrinter(); + $printer->execute(); + ob_start(); + try { + $printer->closePrinter(); + $ret['text'] = ob_get_clean(); + } catch ( Exception $ex ) { + ob_end_clean(); + throw $ex; + } + + if ( !empty( $options['returnPrinter'] ) ) { + $ret['printer'] = $printer; + } + + return count( $ret ) === 1 ? $ret['text'] : $ret; + } + + /** + * @dataProvider provideGeneralEncoding + * @param array $data Data to be encoded + * @param string|Exception $expect String to expect, or exception expected to be thrown + * @param array $params Query parameters to set in the FauxRequest + * @param array $options Options to pass to self::encodeData() + */ + public function testGeneralEncoding( + array $data, $expect, array $params = [], array $options = [] + ) { + if ( $expect instanceof Exception ) { + $this->setExpectedException( get_class( $expect ), $expect->getMessage() ); + $this->encodeData( $params, $data, $options ); // Should throw + } else { + $this->assertSame( $expect, $this->encodeData( $params, $data, $options ) ); + } + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php new file mode 100644 index 00000000..915fb5c5 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php @@ -0,0 +1,123 @@ +<?php + +/** + * @group API + * @group Database + * @covers ApiFormatXml + */ +class ApiFormatXmlTest extends ApiFormatTestBase { + + protected $printerName = 'xml'; + + public static function setUpBeforeClass() { + parent::setUpBeforeClass(); + $page = WikiPage::factory( Title::newFromText( 'MediaWiki:ApiFormatXmlTest.xsl' ) ); + // phpcs:disable Generic.Files.LineLength + $page->doEditContent( new WikitextContent( + '<?xml version="1.0"?><xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" />' + ), 'Summary' ); + // phpcs:enable + $page = WikiPage::factory( Title::newFromText( 'MediaWiki:ApiFormatXmlTest' ) ); + $page->doEditContent( new WikitextContent( 'Bogus' ), 'Summary' ); + $page = WikiPage::factory( Title::newFromText( 'ApiFormatXmlTest' ) ); + $page->doEditContent( new WikitextContent( 'Bogus' ), 'Summary' ); + } + + public static function provideGeneralEncoding() { + // phpcs:disable Generic.Files.LineLength + return [ + // Basic types + [ [ null, 'a' => null ], '<?xml version="1.0"?><api><_v _idx="0" /></api>' ], + [ [ true, 'a' => true ], '<?xml version="1.0"?><api a=""><_v _idx="0">true</_v></api>' ], + [ [ false, 'a' => false ], '<?xml version="1.0"?><api><_v _idx="0">false</_v></api>' ], + [ [ true, 'a' => true, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ], + '<?xml version="1.0"?><api a=""><_v _idx="0">1</_v></api>' ], + [ [ false, 'a' => false, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ], + '<?xml version="1.0"?><api><_v _idx="0"></_v></api>' ], + [ [ 42, 'a' => 42 ], '<?xml version="1.0"?><api a="42"><_v _idx="0">42</_v></api>' ], + [ [ 42.5, 'a' => 42.5 ], '<?xml version="1.0"?><api a="42.5"><_v _idx="0">42.5</_v></api>' ], + [ [ 1e42, 'a' => 1e42 ], '<?xml version="1.0"?><api a="1.0E+42"><_v _idx="0">1.0E+42</_v></api>' ], + [ [ 'foo', 'a' => 'foo' ], '<?xml version="1.0"?><api a="foo"><_v _idx="0">foo</_v></api>' ], + [ [ 'fóo', 'a' => 'fóo' ], '<?xml version="1.0"?><api a="fóo"><_v _idx="0">fóo</_v></api>' ], + + // Arrays and objects + [ [ [] ], '<?xml version="1.0"?><api><_v /></api>' ], + [ [ [ 'x' => 1 ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ], + [ [ [ 2 => 1 ] ], '<?xml version="1.0"?><api><_v><_v _idx="2">1</_v></_v></api>' ], + [ [ (object)[] ], '<?xml version="1.0"?><api><_v /></api>' ], + [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">1</_v></_v></api>' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '<?xml version="1.0"?><api><_v><_v>1</_v></_v></api>' ], + [ [ [ 'x' => 1, 'y' => [ 'z' => 1 ], ApiResult::META_TYPE => 'kvp' ] ], + '<?xml version="1.0"?><api><_v><_v _name="x" xml:space="preserve">1</_v><_v _name="y"><z xml:space="preserve">1</z></_v></_v></api>' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp', ApiResult::META_INDEXED_TAG_NAME => 'i', ApiResult::META_KVP_KEY_NAME => 'key' ] ], + '<?xml version="1.0"?><api><_v><i key="x" xml:space="preserve">1</i></_v></api>' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ], + '<?xml version="1.0"?><api><_v><_v key="x" xml:space="preserve">1</_v></_v></api>' ], + [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ], + [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">a</_v><_v _idx="1">b</_v></_v></api>' ], + + // Content + [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ], + '<?xml version="1.0"?><api xml:space="preserve">foo</api>' ], + + // Specified element name + [ [ 'foo', 'bar', ApiResult::META_INDEXED_TAG_NAME => 'itn' ], + '<?xml version="1.0"?><api><itn>foo</itn><itn>bar</itn></api>' ], + + // Subelements + [ [ 'a' => 1, 's' => 1, '_subelements' => [ 's' ] ], + '<?xml version="1.0"?><api a="1"><s xml:space="preserve">1</s></api>' ], + + // Content and subelement + [ [ 'a' => 1, 'content' => 'foo', ApiResult::META_CONTENT => 'content' ], + '<?xml version="1.0"?><api a="1" xml:space="preserve">foo</api>' ], + [ [ 's' => [], 'content' => 'foo', ApiResult::META_CONTENT => 'content' ], + '<?xml version="1.0"?><api><s /><content xml:space="preserve">foo</content></api>' ], + [ + [ + 's' => 1, + 'content' => 'foo', + ApiResult::META_CONTENT => 'content', + ApiResult::META_SUBELEMENTS => [ 's' ] + ], + '<?xml version="1.0"?><api><s xml:space="preserve">1</s><content xml:space="preserve">foo</content></api>' + ], + + // BC Subelements + [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], + '<?xml version="1.0"?><api><foo xml:space="preserve">foo</foo></api>' ], + + // Name mangling + [ [ 'foo.bar' => 1 ], '<?xml version="1.0"?><api foo.bar="1" />' ], + [ [ '' => 1 ], '<?xml version="1.0"?><api _="1" />' ], + [ [ 'foo bar' => 1 ], '<?xml version="1.0"?><api _foo.20.bar="1" />' ], + [ [ 'foo:bar' => 1 ], '<?xml version="1.0"?><api _foo.3A.bar="1" />' ], + [ [ 'foo%.bar' => 1 ], '<?xml version="1.0"?><api _foo.25..2E.bar="1" />' ], + [ [ '4foo' => 1, 'foo4' => 1 ], '<?xml version="1.0"?><api _4foo="1" foo4="1" />' ], + [ [ "foo\xe3\x80\x80bar" => 1 ], '<?xml version="1.0"?><api _foo.3000.bar="1" />' ], + [ [ 'foo:bar' => 1, ApiResult::META_PRESERVE_KEYS => [ 'foo:bar' ] ], + '<?xml version="1.0"?><api foo:bar="1" />' ], + [ [ 'a', 'b', ApiResult::META_INDEXED_TAG_NAME => 'foo bar' ], + '<?xml version="1.0"?><api><_foo.20.bar>a</_foo.20.bar><_foo.20.bar>b</_foo.20.bar></api>' ], + + // includenamespace param + [ [ 'x' => 'foo' ], '<?xml version="1.0"?><api x="foo" xmlns="http://www.mediawiki.org/xml/api/" />', + [ 'includexmlnamespace' => 1 ] ], + + // xslt param + [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Invalid or non-existent stylesheet specified.</xml></warnings></api>', + [ 'xslt' => 'DoesNotExist' ] ], + [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should be in the MediaWiki namespace.</xml></warnings></api>', + [ 'xslt' => 'ApiFormatXmlTest' ] ], + [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should have ".xsl" extension.</xml></warnings></api>', + [ 'xslt' => 'MediaWiki:ApiFormatXmlTest' ] ], + [ [], + '<?xml version="1.0"?><?xml-stylesheet href="' . + htmlspecialchars( Title::newFromText( 'MediaWiki:ApiFormatXmlTest.xsl' )->getLocalURL( 'action=raw' ) ) . + '" type="text/xsl" ?><api />', + [ 'xslt' => 'MediaWiki:ApiFormatXmlTest.xsl' ] ], + ]; + // phpcs:enable + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/generateRandomImages.php b/www/wiki/tests/phpunit/includes/api/generateRandomImages.php new file mode 100644 index 00000000..d4a5acff --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/generateRandomImages.php @@ -0,0 +1,45 @@ +<?php +/** + * Bootstrapping for test image file generation + * + * @file + */ + +// Start up MediaWiki in command-line mode +require_once __DIR__ . "/../../../../maintenance/Maintenance.php"; +require __DIR__ . "/RandomImageGenerator.php"; + +class GenerateRandomImages extends Maintenance { + + public function getDbType() { + return Maintenance::DB_NONE; + } + + public function execute() { + $getOptSpec = [ + 'dictionaryFile::', + 'minWidth::', + 'maxWidth::', + 'minHeight::', + 'maxHeight::', + 'shapesToDraw::', + 'shape::', + + 'number::', + 'format::' + ]; + $options = getopt( null, $getOptSpec ); + + $format = isset( $options['format'] ) ? $options['format'] : 'jpg'; + unset( $options['format'] ); + + $number = isset( $options['number'] ) ? intval( $options['number'] ) : 10; + unset( $options['number'] ); + + $randomImageGenerator = new RandomImageGenerator( $options ); + $randomImageGenerator->writeImages( $number, $format ); + } +} + +$maintClass = 'GenerateRandomImages'; +require RUN_MAINTENANCE_IF_MAIN; diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php new file mode 100644 index 00000000..e49e1d8b --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php @@ -0,0 +1,346 @@ +<?php +/** + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * 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 + * + * @file + */ + +/** + * These tests validate basic functionality of the api query module + * + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryBasicTest extends ApiQueryTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * +*@see MediaWikiTestCase::addDBDataOnce() + */ + function addDBDataOnce() { + try { + if ( Title::newFromText( 'AQBT-All' )->exists() ) { + return; + } + + // Ordering is important, as it will be returned in the same order as stored in the index + $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' ); + $this->editPage( 'AQBT-Categories', '[[Category:AQBT-Cat]]' ); + $this->editPage( 'AQBT-Links', '[[AQBT-All]] [[AQBT-Categories]] [[AQBT-Templates]]' ); + $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' ); + $this->editPage( 'AQBT-T', 'Content', '', NS_TEMPLATE ); + + // Refresh due to the bug with listing transclusions as links if they don't exist + $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' ); + $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + private static $links = [ + [ 'prop' => 'links', 'titles' => 'AQBT-All' ], + [ 'pages' => [ + '1' => [ + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'links' => [ + [ 'ns' => 0, 'title' => 'AQBT-Links' ], + ] + ] + ] ] + ]; + + private static $templates = [ + [ 'prop' => 'templates', 'titles' => 'AQBT-All' ], + [ 'pages' => [ + '1' => [ + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'templates' => [ + [ 'ns' => 10, 'title' => 'Template:AQBT-T' ], + ] + ] + ] ] + ]; + + private static $categories = [ + [ 'prop' => 'categories', 'titles' => 'AQBT-All' ], + [ 'pages' => [ + '1' => [ + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All', + 'categories' => [ + [ 'ns' => 14, 'title' => 'Category:AQBT-Cat' ], + ] + ] + ] ] + ]; + + private static $allpages = [ + [ 'list' => 'allpages', 'apprefix' => 'AQBT-' ], + [ 'allpages' => [ + [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ], + [ 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ], + [ 'pageid' => 3, 'ns' => 0, 'title' => 'AQBT-Links' ], + [ 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ], + ] ] + ]; + + private static $alllinks = [ + [ 'list' => 'alllinks', 'alprefix' => 'AQBT-' ], + [ 'alllinks' => [ + [ 'ns' => 0, 'title' => 'AQBT-All' ], + [ 'ns' => 0, 'title' => 'AQBT-Categories' ], + [ 'ns' => 0, 'title' => 'AQBT-Links' ], + [ 'ns' => 0, 'title' => 'AQBT-Templates' ], + ] ] + ]; + + private static $alltransclusions = [ + [ 'list' => 'alltransclusions', 'atprefix' => 'AQBT-' ], + [ 'alltransclusions' => [ + [ 'ns' => 10, 'title' => 'Template:AQBT-T' ], + [ 'ns' => 10, 'title' => 'Template:AQBT-T' ], + ] ] + ]; + + // Although this appears to have no use it is used by testLists() + private static $allcategories = [ + [ 'list' => 'allcategories', 'acprefix' => 'AQBT-' ], + [ 'allcategories' => [ + [ 'category' => 'AQBT-Cat' ], + ] ] + ]; + + private static $backlinks = [ + [ 'list' => 'backlinks', 'bltitle' => 'AQBT-Links' ], + [ 'backlinks' => [ + [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ], + ] ] + ]; + + private static $embeddedin = [ + [ 'list' => 'embeddedin', 'eititle' => 'Template:AQBT-T' ], + [ 'embeddedin' => [ + [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ], + [ 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ], + ] ] + ]; + + private static $categorymembers = [ + [ 'list' => 'categorymembers', 'cmtitle' => 'Category:AQBT-Cat' ], + [ 'categorymembers' => [ + [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ], + [ 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ], + ] ] + ]; + + private static $generatorAllpages = [ + [ 'generator' => 'allpages', 'gapprefix' => 'AQBT-' ], + [ 'pages' => [ + '1' => [ + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All' ], + '2' => [ + 'pageid' => 2, + 'ns' => 0, + 'title' => 'AQBT-Categories' ], + '3' => [ + 'pageid' => 3, + 'ns' => 0, + 'title' => 'AQBT-Links' ], + '4' => [ + 'pageid' => 4, + 'ns' => 0, + 'title' => 'AQBT-Templates' ], + ] ] + ]; + + private static $generatorLinks = [ + [ 'generator' => 'links', 'titles' => 'AQBT-Links' ], + [ 'pages' => [ + '1' => [ + 'pageid' => 1, + 'ns' => 0, + 'title' => 'AQBT-All' ], + '2' => [ + 'pageid' => 2, + 'ns' => 0, + 'title' => 'AQBT-Categories' ], + '4' => [ + 'pageid' => 4, + 'ns' => 0, + 'title' => 'AQBT-Templates' ], + ] ] + ]; + + private static $generatorLinksPropLinks = [ + [ 'prop' => 'links' ], + [ 'pages' => [ + '1' => [ 'links' => [ + [ 'ns' => 0, 'title' => 'AQBT-Links' ], + ] ] + ] ] + ]; + + private static $generatorLinksPropTemplates = [ + [ 'prop' => 'templates' ], + [ 'pages' => [ + '1' => [ 'templates' => [ + [ 'ns' => 10, 'title' => 'Template:AQBT-T' ] ] ], + '4' => [ 'templates' => [ + [ 'ns' => 10, 'title' => 'Template:AQBT-T' ] ] ], + ] ] + ]; + + /** + * Test basic props + */ + public function testProps() { + $this->check( self::$links ); + $this->check( self::$templates ); + $this->check( self::$categories ); + } + + /** + * Test basic lists + */ + public function testLists() { + $this->check( self::$allpages ); + $this->check( self::$alllinks ); + $this->check( self::$alltransclusions ); + $this->check( self::$allcategories ); + $this->check( self::$backlinks ); + $this->check( self::$embeddedin ); + $this->check( self::$categorymembers ); + } + + /** + * Test basic lists + */ + public function testAllTogether() { + // All props together + $this->check( $this->merge( + self::$links, + self::$templates, + self::$categories + ) ); + + // All lists together + $this->check( $this->merge( + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers + ) ); + + // All props+lists together + $this->check( $this->merge( + self::$links, + self::$templates, + self::$categories, + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers + ) ); + } + + /** + * Test basic lists + */ + public function testGenerator() { + // generator=allpages + $this->check( self::$generatorAllpages ); + // generator=allpages & list=allpages + $this->check( $this->merge( + self::$generatorAllpages, + self::$allpages ) ); + // generator=links + $this->check( self::$generatorLinks ); + // generator=links & prop=links + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks ) ); + // generator=links & prop=templates + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropTemplates ) ); + // generator=links & prop=links|templates + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks, + self::$generatorLinksPropTemplates ) ); + // generator=links & prop=links|templates & list=allpages|... + $this->check( $this->merge( + self::$generatorLinks, + self::$generatorLinksPropLinks, + self::$generatorLinksPropTemplates, + self::$allpages, + self::$alllinks, + self::$alltransclusions, + // This test is temporarily disabled until a sqlite bug is fixed + // self::$allcategories, + self::$backlinks, + self::$embeddedin, + self::$categorymembers ) ); + } + + /** + * Test T53821 + */ + public function testGeneratorRedirects() { + $this->editPage( 'AQBT-Target', 'test' ); + $this->editPage( 'AQBT-Redir', '#REDIRECT [[AQBT-Target]]' ); + $this->check( [ + [ 'generator' => 'backlinks', 'gbltitle' => 'AQBT-Target', 'redirects' => '1' ], + [ + 'redirects' => [ + [ + 'from' => 'AQBT-Redir', + 'to' => 'AQBT-Target', + ] + ], + 'pages' => [ + '6' => [ + 'pageid' => 6, + 'ns' => 0, + 'title' => 'AQBT-Target', + ] + ], + ] + ] ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php new file mode 100644 index 00000000..334fd5da --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * 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 3 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 + */ + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryContinue2Test extends ApiQueryContinueTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * +*@see MediaWikiTestCase::addDBDataOnce() + */ + function addDBDataOnce() { + try { + $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' ); + $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' ); + $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + /** + * @group medium + */ + public function testA() { + $this->mVerbose = false; + $mk = function ( $g, $p, $gDir ) { + return [ + 'generator' => 'allpages', + 'gapprefix' => 'AQCT73462-', + 'prop' => 'links', + 'gaplimit' => "$g", + 'pllimit' => "$p", + 'gapdir' => $gDir ? "ascending" : "descending", + ]; + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, true ), 1, 'g1p', false ) + + [ 'batchcomplete' => true ]; + $this->checkC( $data, $mk( 1, 1, true ), 6, 'g1p-11t' ); + $this->checkC( $data, $mk( 2, 2, true ), 3, 'g1p-22t' ); + $this->checkC( $data, $mk( 1, 1, false ), 6, 'g1p-11f' ); + $this->checkC( $data, $mk( 2, 2, false ), 3, 'g1p-22f' ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php new file mode 100644 index 00000000..7259bb81 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php @@ -0,0 +1,323 @@ +<?php +/** + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * 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 + */ + +/** + * These tests validate the new continue functionality of the api query module by + * doing multiple requests with varying parameters, merging the results, and checking + * that the result matches the full data received in one no-limits call. + * + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryContinueTest extends ApiQueryContinueTestBase { + protected $exceptionFromAddDBData; + + /** + * Create a set of pages. These must not change, otherwise the tests might give wrong results. + * +*@see MediaWikiTestCase::addDBDataOnce() + */ + function addDBDataOnce() { + try { + $this->editPage( 'Template:AQCT-T1', '**Template:AQCT-T1**' ); + $this->editPage( 'Template:AQCT-T2', '**Template:AQCT-T2**' ); + $this->editPage( 'Template:AQCT-T3', '**Template:AQCT-T3**' ); + $this->editPage( 'Template:AQCT-T4', '**Template:AQCT-T4**' ); + $this->editPage( 'Template:AQCT-T5', '**Template:AQCT-T5**' ); + + $this->editPage( 'AQCT-1', '**AQCT-1** {{AQCT-T2}} {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-2', '[[AQCT-1]] **AQCT-2** {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-3', '[[AQCT-1]] [[AQCT-2]] **AQCT-3** {{AQCT-T4}} {{AQCT-T5}}' ); + $this->editPage( 'AQCT-4', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] **AQCT-4** {{AQCT-T5}}' ); + $this->editPage( 'AQCT-5', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] [[AQCT-4]] **AQCT-5**' ); + } catch ( Exception $e ) { + $this->exceptionFromAddDBData = $e; + } + } + + /** + * Test smart continue - list=allpages + * @group medium + */ + public function test1List() { + $this->mVerbose = false; + $mk = function ( $l ) { + return [ + 'list' => 'allpages', + 'apprefix' => 'AQCT-', + 'aplimit' => "$l", + ]; + }; + $data = $this->query( $mk( 99 ), 1, '1L', false ) + + [ 'batchcomplete' => true ]; + + // 1 list + $this->checkC( $data, $mk( 1 ), 5, '1L-1' ); + $this->checkC( $data, $mk( 2 ), 3, '1L-2' ); + $this->checkC( $data, $mk( 3 ), 2, '1L-3' ); + $this->checkC( $data, $mk( 4 ), 2, '1L-4' ); + $this->checkC( $data, $mk( 5 ), 1, '1L-5' ); + } + + /** + * Test smart continue - list=allpages|alltransclusions + * @group medium + */ + public function test2Lists() { + $this->mVerbose = false; + $mk = function ( $l1, $l2 ) { + return [ + 'list' => 'allpages|alltransclusions', + 'apprefix' => 'AQCT-', + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'aplimit' => "$l1", + 'atlimit' => "$l2", + ]; + }; + // 2 lists + $data = $this->query( $mk( 99, 99 ), 1, '2L', false ) + + [ 'batchcomplete' => true ]; + $this->checkC( $data, $mk( 1, 1 ), 5, '2L-11' ); + $this->checkC( $data, $mk( 2, 2 ), 3, '2L-22' ); + $this->checkC( $data, $mk( 3, 3 ), 2, '2L-33' ); + $this->checkC( $data, $mk( 4, 4 ), 2, '2L-44' ); + $this->checkC( $data, $mk( 5, 5 ), 1, '2L-55' ); + } + + /** + * Test smart continue - generator=allpages, prop=links + * @group medium + */ + public function testGen1Prop() { + $this->mVerbose = false; + $mk = function ( $g, $p ) { + return [ + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links', + 'pllimit' => "$p", + ]; + }; + // generator + 1 prop + $data = $this->query( $mk( 99, 99 ), 1, 'G1P', false ) + + [ 'batchcomplete' => true ]; + $this->checkC( $data, $mk( 1, 1 ), 11, 'G1P-11' ); + $this->checkC( $data, $mk( 2, 2 ), 6, 'G1P-22' ); + $this->checkC( $data, $mk( 3, 3 ), 4, 'G1P-33' ); + $this->checkC( $data, $mk( 4, 4 ), 3, 'G1P-44' ); + $this->checkC( $data, $mk( 5, 5 ), 2, 'G1P-55' ); + } + + /** + * Test smart continue - generator=allpages, prop=links|templates + * @group medium + */ + public function testGen2Prop() { + $this->mVerbose = false; + $mk = function ( $g, $p1, $p2 ) { + return [ + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links|templates', + 'pllimit' => "$p1", + 'tllimit' => "$p2", + ]; + }; + // generator + 2 props + $data = $this->query( $mk( 99, 99, 99 ), 1, 'G2P', false ) + + [ 'batchcomplete' => true ]; + $this->checkC( $data, $mk( 1, 1, 1 ), 16, 'G2P-111' ); + $this->checkC( $data, $mk( 2, 2, 2 ), 9, 'G2P-222' ); + $this->checkC( $data, $mk( 3, 3, 3 ), 6, 'G2P-333' ); + $this->checkC( $data, $mk( 4, 4, 4 ), 4, 'G2P-444' ); + $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G2P-555' ); + $this->checkC( $data, $mk( 5, 1, 1 ), 10, 'G2P-511' ); + $this->checkC( $data, $mk( 4, 2, 2 ), 7, 'G2P-422' ); + $this->checkC( $data, $mk( 2, 3, 3 ), 7, 'G2P-233' ); + $this->checkC( $data, $mk( 2, 4, 4 ), 5, 'G2P-244' ); + $this->checkC( $data, $mk( 1, 5, 5 ), 5, 'G2P-155' ); + } + + /** + * Test smart continue - generator=allpages, prop=links, list=alltransclusions + * @group medium + */ + public function testGen1Prop1List() { + $this->mVerbose = false; + $mk = function ( $g, $p, $l ) { + return [ + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links', + 'pllimit' => "$p", + 'list' => 'alltransclusions', + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'atlimit' => "$l", + ]; + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, 99 ), 1, 'G1P1L', false ) + + [ 'batchcomplete' => true ]; + $this->checkC( $data, $mk( 1, 1, 1 ), 11, 'G1P1L-111' ); + $this->checkC( $data, $mk( 2, 2, 2 ), 6, 'G1P1L-222' ); + $this->checkC( $data, $mk( 3, 3, 3 ), 4, 'G1P1L-333' ); + $this->checkC( $data, $mk( 4, 4, 4 ), 3, 'G1P1L-444' ); + $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G1P1L-555' ); + $this->checkC( $data, $mk( 5, 5, 1 ), 4, 'G1P1L-551' ); + $this->checkC( $data, $mk( 5, 5, 2 ), 2, 'G1P1L-552' ); + } + + /** + * Test smart continue - generator=allpages, prop=links|templates, + * list=alllinks|alltransclusions, meta=siteinfo + * @group medium + */ + public function testGen2Prop2List1Meta() { + $this->mVerbose = false; + $mk = function ( $g, $p1, $p2, $l1, $l2 ) { + return [ + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'prop' => 'links|templates', + 'pllimit' => "$p1", + 'tllimit' => "$p2", + 'list' => 'alllinks|alltransclusions', + 'alprefix' => 'AQCT-', + 'alunique' => '', + 'allimit' => "$l1", + 'atprefix' => 'AQCT-', + 'atunique' => '', + 'atlimit' => "$l2", + 'meta' => 'siteinfo', + 'siprop' => 'namespaces', + ]; + }; + // generator + 1 prop + 1 list + $data = $this->query( $mk( 99, 99, 99, 99, 99 ), 1, 'G2P2L1M', false ) + + [ 'batchcomplete' => true ]; + $this->checkC( $data, $mk( 1, 1, 1, 1, 1 ), 16, 'G2P2L1M-11111' ); + $this->checkC( $data, $mk( 2, 2, 2, 2, 2 ), 9, 'G2P2L1M-22222' ); + $this->checkC( $data, $mk( 3, 3, 3, 3, 3 ), 6, 'G2P2L1M-33333' ); + $this->checkC( $data, $mk( 4, 4, 4, 4, 4 ), 4, 'G2P2L1M-44444' ); + $this->checkC( $data, $mk( 5, 5, 5, 5, 5 ), 2, 'G2P2L1M-55555' ); + $this->checkC( $data, $mk( 5, 5, 5, 1, 1 ), 4, 'G2P2L1M-55511' ); + $this->checkC( $data, $mk( 5, 5, 5, 2, 2 ), 2, 'G2P2L1M-55522' ); + $this->checkC( $data, $mk( 5, 1, 1, 5, 5 ), 10, 'G2P2L1M-51155' ); + $this->checkC( $data, $mk( 5, 2, 2, 5, 5 ), 5, 'G2P2L1M-52255' ); + } + + /** + * Test smart continue - generator=templates, prop=templates + * @group medium + */ + public function testSameGenAndProp() { + $this->mVerbose = false; + $mk = function ( $g, $gDir, $p, $pDir ) { + return [ + 'titles' => 'AQCT-1', + 'generator' => 'templates', + 'gtllimit' => "$g", + 'gtldir' => $gDir ? 'ascending' : 'descending', + 'prop' => 'templates', + 'tllimit' => "$p", + 'tldir' => $pDir ? 'ascending' : 'descending', + ]; + }; + // generator + 1 prop + $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=P', false ) + + [ 'batchcomplete' => true ]; + + $this->checkC( $data, $mk( 1, true, 1, true ), 4, 'G=P-1t1t' ); + $this->checkC( $data, $mk( 2, true, 2, true ), 2, 'G=P-2t2t' ); + $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=P-3t3t' ); + $this->checkC( $data, $mk( 1, true, 3, true ), 4, 'G=P-1t3t' ); + $this->checkC( $data, $mk( 3, true, 1, true ), 2, 'G=P-3t1t' ); + + $this->checkC( $data, $mk( 1, true, 1, false ), 4, 'G=P-1t1f' ); + $this->checkC( $data, $mk( 2, true, 2, false ), 2, 'G=P-2t2f' ); + $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=P-3t3f' ); + $this->checkC( $data, $mk( 1, true, 3, false ), 4, 'G=P-1t3f' ); + $this->checkC( $data, $mk( 3, true, 1, false ), 2, 'G=P-3t1f' ); + + $this->checkC( $data, $mk( 1, false, 1, true ), 4, 'G=P-1f1t' ); + $this->checkC( $data, $mk( 2, false, 2, true ), 2, 'G=P-2f2t' ); + $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=P-3f3t' ); + $this->checkC( $data, $mk( 1, false, 3, true ), 4, 'G=P-1f3t' ); + $this->checkC( $data, $mk( 3, false, 1, true ), 2, 'G=P-3f1t' ); + + $this->checkC( $data, $mk( 1, false, 1, false ), 4, 'G=P-1f1f' ); + $this->checkC( $data, $mk( 2, false, 2, false ), 2, 'G=P-2f2f' ); + $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=P-3f3f' ); + $this->checkC( $data, $mk( 1, false, 3, false ), 4, 'G=P-1f3f' ); + $this->checkC( $data, $mk( 3, false, 1, false ), 2, 'G=P-3f1f' ); + } + + /** + * Test smart continue - generator=allpages, list=allpages + * @group medium + */ + public function testSameGenList() { + $this->mVerbose = false; + $mk = function ( $g, $gDir, $l, $pDir ) { + return [ + 'generator' => 'allpages', + 'gapprefix' => 'AQCT-', + 'gaplimit' => "$g", + 'gapdir' => $gDir ? 'ascending' : 'descending', + 'list' => 'allpages', + 'apprefix' => 'AQCT-', + 'aplimit' => "$l", + 'apdir' => $pDir ? 'ascending' : 'descending', + ]; + }; + // generator + 1 list + $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=L', false ) + + [ 'batchcomplete' => true ]; + + $this->checkC( $data, $mk( 1, true, 1, true ), 5, 'G=L-1t1t' ); + $this->checkC( $data, $mk( 2, true, 2, true ), 3, 'G=L-2t2t' ); + $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=L-3t3t' ); + $this->checkC( $data, $mk( 1, true, 3, true ), 5, 'G=L-1t3t' ); + $this->checkC( $data, $mk( 3, true, 1, true ), 5, 'G=L-3t1t' ); + $this->checkC( $data, $mk( 1, true, 1, false ), 5, 'G=L-1t1f' ); + $this->checkC( $data, $mk( 2, true, 2, false ), 3, 'G=L-2t2f' ); + $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=L-3t3f' ); + $this->checkC( $data, $mk( 1, true, 3, false ), 5, 'G=L-1t3f' ); + $this->checkC( $data, $mk( 3, true, 1, false ), 5, 'G=L-3t1f' ); + $this->checkC( $data, $mk( 1, false, 1, true ), 5, 'G=L-1f1t' ); + $this->checkC( $data, $mk( 2, false, 2, true ), 3, 'G=L-2f2t' ); + $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=L-3f3t' ); + $this->checkC( $data, $mk( 1, false, 3, true ), 5, 'G=L-1f3t' ); + $this->checkC( $data, $mk( 3, false, 1, true ), 5, 'G=L-3f1t' ); + $this->checkC( $data, $mk( 1, false, 1, false ), 5, 'G=L-1f1f' ); + $this->checkC( $data, $mk( 2, false, 2, false ), 3, 'G=L-2f2f' ); + $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=L-3f3f' ); + $this->checkC( $data, $mk( 1, false, 3, false ), 5, 'G=L-1f3f' ); + $this->checkC( $data, $mk( 3, false, 1, false ), 5, 'G=L-3f1f' ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php new file mode 100644 index 00000000..d2bdb496 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php @@ -0,0 +1,210 @@ +<?php +/** + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * 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 + * + * @file + */ +abstract class ApiQueryContinueTestBase extends ApiQueryTestBase { + + /** + * Enable to print in-depth debugging info during the test run + */ + protected $mVerbose = false; + + /** + * Run query() and compare against expected values + * @param array $expected + * @param array $params Api parameters + * @param int $expectedCount Max number of iterations + * @param string $id Unit test id + * @param bool $continue True to use smart continue + * @return array Merged results data array + */ + protected function checkC( $expected, $params, $expectedCount, $id, $continue = true ) { + $result = $this->query( $params, $expectedCount, $id, $continue ); + $this->assertResult( $expected, $result, $id ); + } + + /** + * Run query in a loop until no more values are available + * @param array $params Api parameters + * @param int $expectedCount Max number of iterations + * @param string $id Unit test id + * @param bool $useContinue True to use smart continue + * @return array Merged results data array + * @throws Exception + */ + protected function query( $params, $expectedCount, $id, $useContinue = true ) { + if ( isset( $params['action'] ) ) { + $this->assertEquals( 'query', $params['action'], 'Invalid query action' ); + } else { + $params['action'] = 'query'; + } + $count = 0; + $result = []; + $continue = []; + do { + $request = array_merge( $params, $continue ); + uksort( $request, function ( $a, $b ) { + // put 'continue' params at the end - lazy method + $a = strpos( $a, 'continue' ) !== false ? 'zzz ' . $a : $a; + $b = strpos( $b, 'continue' ) !== false ? 'zzz ' . $b : $b; + + return strcmp( $a, $b ); + } ); + $reqStr = http_build_query( $request ); + // $reqStr = str_replace( '&', ' & ', $reqStr ); + $this->assertLessThan( $expectedCount, $count, "$id more data: $reqStr" ); + if ( $this->mVerbose ) { + print "$id (#$count): $reqStr\n"; + } + try { + $data = $this->doApiRequest( $request ); + } catch ( Exception $e ) { + throw new Exception( "$id on $count", 0, $e ); + } + $data = $data[0]; + if ( isset( $data['warnings'] ) ) { + $warnings = json_encode( $data['warnings'] ); + $this->fail( "$id Warnings on #$count in $reqStr\n$warnings" ); + } + $this->assertArrayHasKey( 'query', $data, "$id no 'query' on #$count in $reqStr" ); + if ( isset( $data['continue'] ) ) { + $continue = $data['continue']; + unset( $data['continue'] ); + } else { + $continue = []; + } + if ( $this->mVerbose ) { + $this->printResult( $data ); + } + $this->mergeResult( $result, $data ); + $count++; + if ( empty( $continue ) ) { + $this->assertEquals( $expectedCount, $count, "$id finished early" ); + + return $result; + } elseif ( !$useContinue ) { + $this->assertFalse( 'Non-smart query must be requested all at once' ); + } + } while ( true ); + } + + /** + * @param array $data + */ + private function printResult( $data ) { + $q = $data['query']; + $print = []; + if ( isset( $q['pages'] ) ) { + foreach ( $q['pages'] as $p ) { + $m = $p['title']; + if ( isset( $p['links'] ) ) { + $m .= '/[' . implode( ',', array_map( + function ( $v ) { + return $v['title']; + }, + $p['links'] ) ) . ']'; + } + if ( isset( $p['categories'] ) ) { + $m .= '/(' . implode( ',', array_map( + function ( $v ) { + return str_replace( 'Category:', '', $v['title'] ); + }, + $p['categories'] ) ) . ')'; + } + $print[] = $m; + } + } + if ( isset( $q['allcategories'] ) ) { + $print[] = '*Cats/(' . implode( ',', array_map( + function ( $v ) { + return $v['*']; + }, + $q['allcategories'] ) ) . ')'; + } + self::GetItems( $q, 'allpages', 'Pages', $print ); + self::GetItems( $q, 'alllinks', 'Links', $print ); + self::GetItems( $q, 'alltransclusions', 'Trnscl', $print ); + print ' ' . implode( ' ', $print ) . "\n"; + } + + private static function GetItems( $q, $moduleName, $name, &$print ) { + if ( isset( $q[$moduleName] ) ) { + $print[] = "*$name/[" . implode( ',', + array_map( + function ( $v ) { + return $v['title']; + }, + $q[$moduleName] ) ) . ']'; + } + } + + /** + * Recursively merge the new result returned from the query to the previous results. + * @param mixed &$results + * @param mixed $newResult + * @param bool $numericIds If true, treat keys as ids to be merged instead of appending + */ + protected function mergeResult( &$results, $newResult, $numericIds = false ) { + $this->assertEquals( + is_array( $results ), + is_array( $newResult ), + 'Type of result and data do not match' + ); + if ( !is_array( $results ) ) { + $this->assertEquals( $results, $newResult, 'Repeated result must be the same as before' ); + } else { + $sort = null; + foreach ( $newResult as $key => $value ) { + if ( !$numericIds && $sort === null ) { + if ( !is_array( $value ) ) { + $sort = false; + } elseif ( array_key_exists( 'title', $value ) ) { + $sort = function ( $a, $b ) { + return strcmp( $a['title'], $b['title'] ); + }; + } else { + $sort = false; + } + } + $keyExists = array_key_exists( $key, $results ); + if ( is_numeric( $key ) ) { + if ( $numericIds ) { + if ( !$keyExists ) { + $results[$key] = $value; + } else { + $this->mergeResult( $results[$key], $value ); + } + } else { + $results[] = $value; + } + } elseif ( !$keyExists ) { + $results[$key] = $value; + } else { + $this->mergeResult( $results[$key], $value, $key === 'pages' ); + } + } + if ( $numericIds ) { + ksort( $results, SORT_NUMERIC ); + } elseif ( $sort !== null && $sort !== false ) { + usort( $results, $sort ); + } + } + } +} diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php new file mode 100644 index 00000000..38a1d685 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php @@ -0,0 +1,44 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQueryRevisions + */ +class ApiQueryRevisionsTest extends ApiTestCase { + + /** + * @group medium + */ + public function testContentComesWithContentModelAndFormat() { + $pageName = 'Help:' . __METHOD__; + $title = Title::newFromText( $pageName ); + $page = WikiPage::factory( $title ); + + $page->doEditContent( + ContentHandler::makeContent( 'Some text', $page->getTitle() ), + 'inserting content' + ); + + $apiResult = $this->doApiRequest( [ + 'action' => 'query', + 'prop' => 'revisions', + 'titles' => $pageName, + 'rvprop' => 'content', + ] ); + $this->assertArrayHasKey( 'query', $apiResult[0] ); + $this->assertArrayHasKey( 'pages', $apiResult[0]['query'] ); + foreach ( $apiResult[0]['query']['pages'] as $page ) { + $this->assertArrayHasKey( 'revisions', $page ); + foreach ( $page['revisions'] as $revision ) { + $this->assertArrayHasKey( 'contentformat', $revision, + 'contentformat should be included when asking content so client knows how to interpret it' + ); + $this->assertArrayHasKey( 'contentmodel', $revision, + 'contentmodel should be included when asking content so client knows how to interpret it' + ); + } + } + } +} diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php new file mode 100644 index 00000000..de8d8156 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php @@ -0,0 +1,151 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQuery + */ +class ApiQueryTest extends ApiTestCase { + protected function setUp() { + parent::setUp(); + + // Setup apiquerytestiw: as interwiki prefix + $this->setMwGlobals( 'wgHooks', [ + 'InterwikiLoadPrefix' => [ + function ( $prefix, &$data ) { + if ( $prefix == 'apiquerytestiw' ) { + $data = [ 'iw_url' => 'wikipedia' ]; + } + return false; + } + ] + ] ); + } + + public function testTitlesGetNormalized() { + global $wgMetaNamespace; + + $this->setMwGlobals( [ + 'wgCapitalLinks' => true, + ] ); + + $data = $this->doApiRequest( [ + 'action' => 'query', + 'titles' => 'Project:articleA|article_B' ] ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'normalized', $data[0]['query'] ); + + // Forge a normalized title + $to = Title::newFromText( $wgMetaNamespace . ':ArticleA' ); + + $this->assertEquals( + [ + 'fromencoded' => false, + 'from' => 'Project:articleA', + 'to' => $to->getPrefixedText(), + ], + $data[0]['query']['normalized'][0] + ); + + $this->assertEquals( + [ + 'fromencoded' => false, + 'from' => 'article_B', + 'to' => 'Article B' + ], + $data[0]['query']['normalized'][1] + ); + } + + public function testTitlesAreRejectedIfInvalid() { + $title = false; + while ( !$title || Title::newFromText( $title )->exists() ) { + $title = md5( mt_rand( 0, 100000 ) ); + } + + $data = $this->doApiRequest( [ + 'action' => 'query', + 'titles' => $title . '|Talk:' ] ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'pages', $data[0]['query'] ); + $this->assertEquals( 2, count( $data[0]['query']['pages'] ) ); + + $this->assertArrayHasKey( -2, $data[0]['query']['pages'] ); + $this->assertArrayHasKey( -1, $data[0]['query']['pages'] ); + + $this->assertArrayHasKey( 'missing', $data[0]['query']['pages'][-2] ); + $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] ); + } + + public function testTitlesWithWhitespaces() { + $data = $this->doApiRequest( [ + 'action' => 'query', + 'titles' => ' ' + ] ); + + $this->assertArrayHasKey( 'query', $data[0] ); + $this->assertArrayHasKey( 'pages', $data[0]['query'] ); + $this->assertEquals( 1, count( $data[0]['query']['pages'] ) ); + $this->assertArrayHasKey( -1, $data[0]['query']['pages'] ); + $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] ); + } + + /** + * Test the ApiBase::titlePartToKey function + * + * @param string $titlePart + * @param int $namespace + * @param string $expected + * @param string $expectException + * @dataProvider provideTestTitlePartToKey + */ + function testTitlePartToKey( $titlePart, $namespace, $expected, $expectException ) { + $this->setMwGlobals( [ + 'wgCapitalLinks' => true, + ] ); + + $api = new MockApiQueryBase(); + $exceptionCaught = false; + try { + $this->assertEquals( $expected, $api->titlePartToKey( $titlePart, $namespace ) ); + } catch ( ApiUsageException $e ) { + $exceptionCaught = true; + } + $this->assertEquals( $expectException, $exceptionCaught, + 'ApiUsageException thrown by titlePartToKey' ); + } + + function provideTestTitlePartToKey() { + return [ + [ 'a b c', NS_MAIN, 'A_b_c', false ], + [ 'x', NS_MAIN, 'X', false ], + [ 'y ', NS_MAIN, 'Y_', false ], + [ 'template:foo', NS_CATEGORY, 'Template:foo', false ], + [ 'apiquerytestiw:foo', NS_CATEGORY, 'Apiquerytestiw:foo', false ], + [ "\xF7", NS_MAIN, null, true ], + [ 'template:foo', NS_MAIN, null, true ], + [ 'apiquerytestiw:foo', NS_MAIN, null, true ], + ]; + } + + /** + * Test if all classes in the query module manager exists + */ + public function testClassNamesInModuleManager() { + $api = new ApiMain( + new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) + ); + $queryApi = new ApiQuery( $api, 'query' ); + $modules = $queryApi->getModuleManager()->getNamesWithClasses(); + + foreach ( $modules as $name => $class ) { + $this->assertTrue( + class_exists( $class ), + 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)' + ); + } + } +} diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php new file mode 100644 index 00000000..e7588cb5 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php @@ -0,0 +1,158 @@ +<?php +/** + * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" + * + * 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 3 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 + * + * @file + */ + +/** This class has some common functionality for testing query module + */ +abstract class ApiQueryTestBase extends ApiTestCase { + + const PARAM_ASSERT = <<<STR +Each parameter must be an array of two elements, +first - an array of params to the API call, +and the second array - expected results as returned by the API +STR; + + /** + * Merges all requests parameter + expected values into one + * @param array $v,... List of arrays, each of which contains exactly two + * @return array + */ + protected function merge( /*...*/ ) { + $request = []; + $expected = []; + foreach ( func_get_args() as $v ) { + list( $req, $exp ) = $this->validateRequestExpectedPair( $v ); + $request = array_merge_recursive( $request, $req ); + $this->mergeExpected( $expected, $exp ); + } + + return [ $request, $expected ]; + } + + /** + * Check that the parameter is a valid two element array, + * with the first element being API request and the second - expected result + * @param array $v + * @return array + */ + private function validateRequestExpectedPair( $v ) { + $this->assertInternalType( 'array', $v, self::PARAM_ASSERT ); + $this->assertEquals( 2, count( $v ), self::PARAM_ASSERT ); + $this->assertArrayHasKey( 0, $v, self::PARAM_ASSERT ); + $this->assertArrayHasKey( 1, $v, self::PARAM_ASSERT ); + $this->assertInternalType( 'array', $v[0], self::PARAM_ASSERT ); + $this->assertInternalType( 'array', $v[1], self::PARAM_ASSERT ); + + return $v; + } + + /** + * Recursively merges the expected values in the $item into the $all + * @param array &$all + * @param array $item + */ + private function mergeExpected( &$all, $item ) { + foreach ( $item as $k => $v ) { + if ( array_key_exists( $k, $all ) ) { + if ( is_array( $all[$k] ) ) { + $this->mergeExpected( $all[$k], $v ); + } else { + $this->assertEquals( $all[$k], $v ); + } + } else { + $all[$k] = $v; + } + } + } + + /** + * Checks that the request's result matches the expected results. + * Assumes no rawcontinue and a complete batch. + * @param array $values Array is a two element array( request, expected_results ) + * @param array $session + * @param bool $appendModule + * @param User $user + */ + protected function check( $values, array $session = null, + $appendModule = false, User $user = null + ) { + list( $req, $exp ) = $this->validateRequestExpectedPair( $values ); + if ( !array_key_exists( 'action', $req ) ) { + $req['action'] = 'query'; + } + foreach ( $req as &$val ) { + if ( is_array( $val ) ) { + $val = implode( '|', array_unique( $val ) ); + } + } + $result = $this->doApiRequest( $req, $session, $appendModule, $user ); + $this->assertResult( [ 'batchcomplete' => true, 'query' => $exp ], $result[0], $req ); + } + + protected function assertResult( $exp, $result, $message = '' ) { + try { + $exp = self::sanitizeResultArray( $exp ); + $result = self::sanitizeResultArray( $result ); + $this->assertEquals( $exp, $result ); + } catch ( PHPUnit_Framework_ExpectationFailedException $e ) { + if ( is_array( $message ) ) { + $message = http_build_query( $message ); + } + + // FIXME: once we migrate to phpunit 4.1+, hardcode ComparisonFailure exception use + $compEx = 'SebastianBergmann\Comparator\ComparisonFailure'; + if ( !class_exists( $compEx ) ) { + $compEx = 'PHPUnit_Framework_ComparisonFailure'; + } + + throw new PHPUnit_Framework_ExpectationFailedException( + $e->getMessage() . "\nRequest: $message", + new $compEx( + $exp, + $result, + print_r( $exp, true ), + print_r( $result, true ), + false, + $e->getComparisonFailure()->getMessage() . "\nRequest: $message" + ) + ); + } + } + + /** + * Recursively ksorts a result array and removes any 'pageid' keys. + * @param array $result + * @return array + */ + private static function sanitizeResultArray( $result ) { + unset( $result['pageid'] ); + foreach ( $result as $key => $value ) { + if ( is_array( $value ) ) { + $result[$key] = self::sanitizeResultArray( $value ); + } + } + + // Sort the result by keys, then take advantage of how array_merge will + // renumber numeric keys while leaving others alone. + ksort( $result ); + return array_merge( $result ); + } +} diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php new file mode 100644 index 00000000..ca6a929a --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php @@ -0,0 +1,194 @@ +<?php + +/** + * @group API + * @group Database + * @group medium + * @covers ApiQueryContributions + */ +class ApiQueryContributionsTest extends ApiTestCase { + public function addDBDataOnce() { + global $wgActorTableSchemaMigrationStage; + + $reset = new \Wikimedia\ScopedCallback( function ( $v ) { + global $wgActorTableSchemaMigrationStage; + $wgActorTableSchemaMigrationStage = $v; + $this->overrideMwServices(); + }, [ $wgActorTableSchemaMigrationStage ] ); + $wgActorTableSchemaMigrationStage = MIGRATION_WRITE_BOTH; + $this->overrideMwServices(); + + $users = [ + User::newFromName( '192.168.2.2', false ), + User::newFromName( '192.168.2.1', false ), + User::newFromName( '192.168.2.3', false ), + User::createNew( __CLASS__ . ' B' ), + User::createNew( __CLASS__ . ' A' ), + User::createNew( __CLASS__ . ' C' ), + User::newFromName( 'IW>' . __CLASS__, false ), + ]; + + $title = Title::newFromText( __CLASS__ ); + $page = WikiPage::factory( $title ); + for ( $i = 0; $i < 3; $i++ ) { + foreach ( array_reverse( $users ) as $user ) { + $status = $page->doEditContent( + ContentHandler::makeContent( "Test revision $user #$i", $title ), 'Test edit', 0, false, $user + ); + if ( !$status->isOK() ) { + $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) ); + } + } + } + } + + /** + * @dataProvider provideSorting + * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage + * @param array $params Extra parameters for the query + * @param bool $reverse Reverse order? + * @param int $revs Number of revisions to expect + */ + public function testSorting( $stage, $params, $reverse, $revs ) { + if ( isset( $params['ucuserprefix'] ) && + ( $stage === MIGRATION_WRITE_BOTH || $stage === MIGRATION_WRITE_NEW ) && + $this->db->getType() === 'mysql' && $this->usesTemporaryTables() + ) { + // https://bugs.mysql.com/bug.php?id=10327 + $this->markTestSkipped( 'MySQL bug 10327 - can\'t reopen temporary tables' ); + } + + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage ); + $this->overrideMwServices(); + + if ( isset( $params['ucuserids'] ) ) { + $params['ucuserids'] = implode( '|', array_map( 'User::idFromName', $params['ucuserids'] ) ); + } + if ( isset( $params['ucuser'] ) ) { + $params['ucuser'] = implode( '|', $params['ucuser'] ); + } + + $sort = 'rsort'; + if ( $reverse ) { + $params['ucdir'] = 'newer'; + $sort = 'sort'; + } + + $params += [ + 'action' => 'query', + 'list' => 'usercontribs', + 'ucprop' => 'ids', + ]; + + $apiResult = $this->doApiRequest( $params + [ 'uclimit' => 500 ] ); + $this->assertArrayNotHasKey( 'continue', $apiResult[0] ); + $this->assertArrayHasKey( 'query', $apiResult[0] ); + $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] ); + + $count = 0; + $ids = []; + foreach ( $apiResult[0]['query']['usercontribs'] as $page ) { + $count++; + $ids[$page['user']][] = $page['revid']; + } + $this->assertSame( $revs, $count, 'Expected number of revisions' ); + foreach ( $ids as $user => $revids ) { + $sorted = $revids; + call_user_func_array( $sort, [ &$sorted ] ); + $this->assertSame( $sorted, $revids, "IDs for $user are sorted" ); + } + + for ( $limit = 1; $limit < $revs; $limit++ ) { + $continue = []; + $count = 0; + $batchedIds = []; + while ( $continue !== null ) { + $apiResult = $this->doApiRequest( $params + [ 'uclimit' => $limit ] + $continue ); + $this->assertArrayHasKey( 'query', $apiResult[0], "Batching with limit $limit" ); + $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'], + "Batching with limit $limit" ); + $continue = isset( $apiResult[0]['continue'] ) ? $apiResult[0]['continue'] : null; + foreach ( $apiResult[0]['query']['usercontribs'] as $page ) { + $count++; + $batchedIds[$page['user']][] = $page['revid']; + } + $this->assertLessThanOrEqual( $revs, $count, "Batching with limit $limit" ); + } + $this->assertSame( $ids, $batchedIds, "Result set is the same when batching with limit $limit" ); + } + } + + public static function provideSorting() { + $users = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' C' ]; + $users2 = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' D' ]; + $ips = [ '192.168.2.1', '192.168.2.2', '192.168.2.3', '192.168.2.4' ]; + + foreach ( + [ + 'old' => MIGRATION_OLD, + 'write both' => MIGRATION_WRITE_BOTH, + 'write new' => MIGRATION_WRITE_NEW, + 'new' => MIGRATION_NEW, + ] as $stageName => $stage + ) { + foreach ( [ false, true ] as $reverse ) { + $name = $stageName . ( $reverse ? ', reverse' : '' ); + yield "Named users, $name" => [ $stage, [ 'ucuser' => $users ], $reverse, 9 ]; + yield "Named users including a no-edit user, $name" => [ + $stage, [ 'ucuser' => $users2 ], $reverse, 6 + ]; + yield "IP users, $name" => [ $stage, [ 'ucuser' => $ips ], $reverse, 9 ]; + yield "All users, $name" => [ + $stage, [ 'ucuser' => array_merge( $users, $ips ) ], $reverse, 18 + ]; + yield "User IDs, $name" => [ $stage, [ 'ucuserids' => $users ], $reverse, 9 ]; + yield "Users by prefix, $name" => [ $stage, [ 'ucuserprefix' => __CLASS__ ], $reverse, 9 ]; + yield "IPs by prefix, $name" => [ $stage, [ 'ucuserprefix' => '192.168.2.' ], $reverse, 9 ]; + } + } + } + + /** + * @dataProvider provideInterwikiUser + * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage + */ + public function testInterwikiUser( $stage ) { + $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage ); + $this->overrideMwServices(); + + $params = [ + 'action' => 'query', + 'list' => 'usercontribs', + 'ucuser' => 'IW>' . __CLASS__, + 'ucprop' => 'ids', + 'uclimit' => 'max', + ]; + + $apiResult = $this->doApiRequest( $params ); + $this->assertArrayNotHasKey( 'continue', $apiResult[0] ); + $this->assertArrayHasKey( 'query', $apiResult[0] ); + $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] ); + + $count = 0; + $ids = []; + foreach ( $apiResult[0]['query']['usercontribs'] as $page ) { + $count++; + $this->assertSame( 'IW>' . __CLASS__, $page['user'], 'Correct user returned' ); + $ids[] = $page['revid']; + } + $this->assertSame( 3, $count, 'Expected number of revisions' ); + $sorted = $ids; + rsort( $sorted ); + $this->assertSame( $sorted, $ids, "IDs are sorted" ); + } + + public static function provideInterwikiUser() { + return [ + 'old' => [ MIGRATION_OLD ], + 'write both' => [ MIGRATION_WRITE_BOTH ], + 'write new' => [ MIGRATION_WRITE_NEW ], + 'new' => [ MIGRATION_NEW ], + ]; + } + +} diff --git a/www/wiki/tests/phpunit/includes/api/words.txt b/www/wiki/tests/phpunit/includes/api/words.txt new file mode 100644 index 00000000..7ce23ee3 --- /dev/null +++ b/www/wiki/tests/phpunit/includes/api/words.txt @@ -0,0 +1,1000 @@ +Andaquian +Anoplanthus +Araquaju +Astrophyton +Avarish +Batonga +Bdellidae +Betoyan +Bismarck +Britishness +Carmen +Chatillon +Clement +Coryphaena +Croton +Cyrillianism +Dagomba +Decimus +Dichorisandra +Duculinae +Empusa +Escallonia +Fathometer +Fon +Fundulinae +Gadswoons +Gederathite +Gemini +Gerbera +Gregarinida +Gyracanthus +Halopsychidae +Hasidim +Hemerobius +Ichthyosauridae +Iscariot +Jeames +Jesuitry +Jovian +Judaization +Katie +Ladin +Langhian +Lapithaean +Lisette +Macrochira +Malaxis +Malvastrum +Maranhao +Marxian +Maurist +Metrosideros +Micky +Microsporon +Odacidae +Ophiuchid +Osmorhiza +Paguma +Palesman +Papayaceae +Pastinaca +Philoxenian +Pleurostigma +Rarotongan +Rhodoraceae +Rong +Saho +Sanyakoan +Sardanapalian +Sauropoda +Sedentaria +Shambu +Shukulumbwe +Solonian +Spaniardization +Spirochaetaceae +Stomatopoda +Stratiotes +Taiwanhemp +Titanically +Venetianed +Victrola +Yuman +abatis +abaton +abjoint +acanthoma +acari +acceptance +actinography +acuteness +addiment +adelite +adelomorphic +adelphogamy +adipocele +aelurophobia +affined +aflaunt +agathokakological +aischrolatreia +alarmedly +alebench +aleurone +allelotropic +allerion +alloplastic +allowable +alternacy +alternariose +altricial +ambitionist +amendment +amiableness +amicableness +ammo +amortizable +anchorate +anemometrically +angelocracy +angelological +anodal +anomalure +antedate +antiagglutinin +antirationalist +antiscorbutic +antisplasher +antithesize +antiunionist +antoecian +apolegamic +appropriation +archididascalian +archival +arteriophlebotomy +articulable +asseveration +assignation +atelo +atrienses +atrophy +atterminement +atypic +automower +aveloz +awrist +azteca +bairnteam +balsamweed +bannerman +beardy +becry +beek +beggarwise +bescab +bestness +bethel +bewildering +bibliophilism +bitterblain +blakeberyed +boccarella +bocedization +boobyalla +bourbon +bowbent +bowerbird +brachygnathous +brail +branchiferous +brelaw +brew +brideweed +bridgeable +brombenzamide +buddler +burbankian +burr +buskin +cacochymical +calefactory +caliper +canaliculus +candidature +canellaceous +canniness +canning +cantilene +carbonatation +carthamic +caseum +caudated +causationist +ceruleite +chalder +chalta +charmel +chekan +chillness +chirogymnast +chirpling +chlorinous +cholanthrene +chondroblast +chromatography +chromophilous +chronical +cicatrice +cinchonine +city +clubbing +coastal +coaxially +coercible +coeternity +coff +coinventor +collyba +combinator +complanation +comprehensibility +conchuela +congenital +context +contranatural +corallum +cordately +cornupete +corolliferous +coroneted +corticosterone +coseat +cottage +crocetin +crossleted +crottels +curvedness +cycadeous +cyclism +cylindrically +cynanche +cyrtoceratitic +cystospasm +danceress +dancette +dawny +daydreamy +debar +decarburization +decorousness +decrepitness +delirious +deozonizer +dermatosis +desma +deutencephalic +diacetate +diarthrodial +diathermy +dicolic +dimastigate +dimidiation +dipetto +disavowable +disintrench +disman +dismay +disorder +disoxygenation +dithionous +dogman +dragonfly +dramatical +drawspan +drubbly +drunk +duskly +ecderonic +ectocuniform +ectocyst +ehrwaldite +electrocute +elemicin +embracing +emotionality +enactment +enamor +enclave +endameba +endochylous +endocrinologist +endolymph +endothecal +entasia +epigeous +episcopicide +epitrichial +erminee +erraticalness +eruptivity +erythrocytoschisis +esperance +estuous +eucrystalline +eugeny +evacuant +everbloomer +evocation +exarchateship +exasperate +excorticate +excrementary +exile +expandedly +exponency +expressionist +expulsion +extemporary +extollation +extortive +extrabulbar +extraprostatic +facticide +fairer +fakery +fasibitikite +fatiscent +fearless +febrifuge +ferie +fibrousness +fingered +fisheye +flagpole +flagrantness +fleche +fluidism +folliculin +footbreadth +forceps +forecontrive +forthbring +foveated +fuchsin +fungicidal +funori +gamelang +gametically +garvanzo +gasoliner +gastrophile +germproof +gerontism +gigantical +glaciology +godmotherhood +gooseherd +gordunite +gove +gracilis +greathead +grieveship +guidable +gyromancy +gyrostat +habitus +hailweed +handhole +hangalai +haznadar +heliced +hemihypertrophy +hemimorphic +hemistrumectomy +heptavalent +heptite +herbalist +herpetology +hesperid +hexacarbon +hieromnemon +hobbyless +holodactylic +homoeoarchy +hopperings +hospitable +houseboat +huh +huntedly +hydroponics +hydrosomal +hyperdactylia +hyperperistalsis +hypogeocarpous +ideogram +idiopathical +illegitimate +imambarah +impotently +improvise +impuberal +inaccurately +incarnant +inchoation +incliner +incredulous +indiscriminateness +indulgenced +inebriation +inexpressiveness +infibulate +inflectedness +iniome +ink +inquietly +insaturable +insinuative +instiller +institutive +insultproof +interactionist +intercensal +interpenetrable +intertranspicuous +intrinsicality +inwards +iridiocyte +iridoparalysis +irreportable +isoprene +isosmotic +izard +jacuaru +jaculative +jerkined +joe +joyous +julienne +justicehood +kali +kalidium +katha +kathal +keelage +keratomycosis +khaki +khedival +kinkily +knife +kolo +kraken +kwarta +labba +labber +laboress +lacunar +latch +lauric +lawter +lectotype +leeches +legible +lepidosteoid +leucobasalt +leverer +libellate +limnimeter +lithography +lithotypic +locomotor +logarithmetically +logistician +lyncine +lysogenesis +machan +macromyelon +maharana +mandibulate +manganapatite +marchpane +mas +masochistic +mastaba +matching +meditatively +megalopolitan +melaniline +mentum +mercaptides +mestome +metasomatism +meterless +micronuclear +micropetalous +microreaction +microsporophore +mileway +milliarium +millisecond +misbind +miscollocation +misreader +modernicide +modification +modulant +monkfish +monoamino +monocarbide +monographical +morphinomaniac +mullein +munge +mutilate +mycophagist +myelosarcoma +myospasm +myriadly +nagaika +naphthionate +natant +naviculaeform +nayward +neallotype +necrophilia +nectared +neigher +neogamous +neurodynia +neurorthopteran +nidation +nieceship +nitrobacteria +nitrosification +nogheaded +nonassertive +noneuphonious +nonextant +nonincrease +nonintermittent +nonmetallic +nonprehensile +nonremunerative +nonsocial +nonvesting +noontime +noreaster +nounal +nub +nucleoplasm +nullisome +numero +numerous +oblongatal +observe +obtusilingual +obvert +occipitoatlantal +oceanside +ochlophobist +odontiasis +opalescence +opticon +oraculousness +orarium +organically +orthopedically +ostosis +overadvance +overbuilt +overdiscouragement +overdoer +overhardy +overjocular +overmagnify +overofficered +overpotent +overprizer +overrunner +overshrink +oversimply +oversplash +ovology +oxskin +oxychloride +oxygenant +ozokerite +pactional +palaeoanthropography +palaeographical +palaeopsychology +palliasse +palpebral +pandaric +pantelegraph +papicolist +papulate +parakinetic +parasitism +parochialic +parochialize +passionlike +patch +paucidentate +pawnbrokeress +pecite +pecky +pedipulation +pellitory +perfilograph +periblast +perigemmal +periost +periplus +perishable +periwig +permansive +persistingly +persymmetrical +phantom +phasmatrope +philocaly +philogyny +philosophister +philotherianism +phorology +phototrophic +phrator +phratral +phthisipneumony +physogastry +phytologic +phytoptid +pianograph +picqueter +piculet +pigeoner +pimaric +pinesap +pist +planometer +platano +playful +plea +pleuropneumonic +plowwoman +plump +pluviographical +pneumocele +podophthalmate +polyad +polythalamian +poppyhead +portamento +portmanteau +portraitlike +possible +potassamide +powderer +praepubis +preanesthetic +prebarbaric +predealer +predomination +prefactory +preirrigational +prelector +presbytership +presecure +preservable +prespecialist +preventionism +prewound +princely +priorship +proannexationist +proanthropos +probeable +probouleutic +profitless +proplasma +prosectorial +protecting +protochemistry +protosulphate +pseudoataxia +psilology +psychoneurotic +pterygial +publicist +purgation +purplishness +putatively +pyracene +pyrenomycete +pyromancy +pyrophone +quadroon +quailhead +qualifier +quaternal +rabblelike +rambunctious +rapidness +ratably +rationalism +razor +reannoy +recultivation +regulable +reimplant +reimposition +reimprison +reinjure +reinspiration +reintroduce +remantle +reprehensibility +reptant +require +resteal +restful +returnability +revisableness +rewash +rewhirl +reyield +rhizotomy +rhodamine +rigwiddie +rimester +ripper +rippet +rockish +rockwards +rollicky +roosters +rooted +rosal +rozum +saccharated +sagamore +sagy +salesmanship +salivous +sallet +salta +saprostomous +satiation +sauropsid +sawarra +sawback +scabish +scabrate +scampavia +scientificophilosophical +scirrosity +scoliometer +scolopendrelloid +secantly +seignioral +semibull +semic +seminarianism +semiped +semiprivate +semispherical +semispontaneous +seneschal +septendecimal +serotherapist +servation +sesquisulphuret +severish +sextipartite +sextubercular +shipyard +shuckpen +siderosis +silex +sillyhow +silverbelly +silverbelly +simulacrum +sisham +sixte +skeiner +skiapod +slopped +slubby +smalts +sockmaker +solute +somethingness +somnify +southwester +spathilla +spectrochemical +sphagnology +spinales +spiriting +spirling +spirochetemia +spreadboard +spurflower +squawdom +squeezing +staircase +staker +stamphead +statolith +stekan +stellulate +stinker +stomodaea +streamingly +strikingness +strouthocamelian +stuprum +subacutely +subboreal +subcontractor +subendorsement +subprofitable +subserviate +subsneer +subungual +sucuruju +sugan +sulphocarbolate +summerwood +superficialist +superinference +superregenerative +supplicate +suspendible +synchronizer +syntectic +tachyglossate +tailless +taintment +takingly +taletelling +tarpon +tasteful +taxeater +taxy +teache +teachless +teg +tegmen +teletyper +temperable +ten +tenent +teskere +testes +thallogen +thapsia +thewness +thickety +thiobacteria +thorniness +throwing +thyroprivic +tinnitus +tocalote +tolerationist +tonalamatl +torvous +totality +tottering +toug +tracheopathia +tragedical +translucent +trifoveolate +trilaurin +trophoplasmatic +trunkless +turbanless +turnpiker +twangle +twitterboned +ultraornate +umbilication +unabatingly +unabjured +unadequateness +unaffectedness +unarriving +unassorted +unattacked +unbenumbed +unboasted +unburning +uncensorious +uncongested +uncontemnedly +uncontemporary +uncrook +uncrystallizability +uncurb +uncustomariness +underbillow +undercanopy +underestimation +underhanging +underpetticoated +underpropped +undersole +understocking +underworld +undevout +undisappointing +undistinctive +unfiscal +unfluted +unfreckled +ungentilize +unglobe +unhelped +unhomogeneously +unifoliate +uninflammable +uninterrogated +unisonal +unkindled +unlikeableness +unlisty +unlocked +unmoving +unmultipliable +unnestled +unnoticed +unobservable +unobviated +unoffensively +unofficerlike +unpoetic +unpractically +unquestionableness +unrehearsed +unrevised +unrhetorical +unsadden +unsaluting +unscriptural +unseeking +unshowed +unsolicitous +unsprouted +unsubjective +unsubsidized +unsymbolic +untenant +unterrified +untranquil +untraversed +untrusty +untying +unwillful +unwinding +upspring +uptwist +urachovesical +uropygial +vagabondism +varicoid +varletess +vasal +ventrocaudal +verisimilitude +vermigerous +vibrometer +viminal +virus +vocationalism +voguey +vulnerability +waggle +wamblingly +warmus +waxer +waying +wedgeable +wellmaker +whomever +wigged +witchlike +wokas +woodrowel +woodsman +woolding +xanthelasmic +xiphosternum +yachtman +yachtsmanlike +yelp +zoophytal
\ No newline at end of file |