From 20d18cf3cb118cd09028e4d4f4939850e5023207 Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Thu, 27 Sep 2018 11:04:24 -0400 Subject: [PATCH] API: Allow prop=info intestactions to return reasons T194585 raises a use case for callers to be able to know why an action is not allowed. We can make that possible easily enough. The default remains to return only a boolean. This also deprecates inprop=readable in favor of intestactions=read, since they both just return `$title->userCan( 'read', $user )`. (ApiQueryInfoTest added by David Barratt) Bug: T194585 Change-Id: Ib880f0605880eac776d816ea04e0c7ab9cfbaab1 Co-Authored-By: David Barratt --- RELEASE-NOTES-1.32 | 9 ++ includes/api/ApiErrorFormatter.php | 24 +++ includes/api/ApiQueryInfo.php | 26 +++- includes/api/i18n/en.json | 6 +- includes/api/i18n/qqq.json | 4 + .../includes/api/ApiErrorFormatterTest.php | 21 +++ .../phpunit/includes/api/ApiQueryInfoTest.php | 146 ++++++++++++++++++ 7 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 tests/phpunit/includes/api/ApiQueryInfoTest.php diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 4ae590d215..9e7128595c 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -180,6 +180,10 @@ production. * (T198214) The 'disabletidy' parameter to action=parse has been deprecated; untidy output will not be supported by future wikitext parsers. +* Added intestactionsdetail to action=query&prop=info to allow retrieving the + reasons an action is not allowed. +* Deprecated action=query&prop=info inprop=readable in favor of + intestactions=read. === Action API internal changes in 1.32 === * Added 'ApiParseMakeOutputPage' hook. @@ -208,6 +212,9 @@ production. * ApiUsageException::getCodeString() (deprecated in 1.29) * ApiUsageException::getMessageArray() (deprecated in 1.29) * Class UsageException, deprecated in 1.29, has been removed. +* ApiErrorFormatter: Added getFormat() and newWithFormat(). In particular, you + can now easily test $formatter->getFormat() === 'bc', and then call + $formatter->newWithFormat( 'plaintext' ) to get a non-BC formatter. === Languages updated in 1.32 === MediaWiki supports over 350 languages. Many localisations are updated regularly. @@ -568,6 +575,8 @@ because of Phabricator reports. * The $wgUseKeyHeader configuration option and the OutputPage::getKeyHeader() method have been deprecated; the relevant draft IETF spec expired without becoming a standard. +* Deprecated API action=query&prop=info inprop=readable in favor of + intestactions=read. === Other changes in 1.32 === * (T198811) The following tables have had their UNIQUE indexes turned into diff --git a/includes/api/ApiErrorFormatter.php b/includes/api/ApiErrorFormatter.php index 5a52c5f143..847afd8634 100644 --- a/includes/api/ApiErrorFormatter.php +++ b/includes/api/ApiErrorFormatter.php @@ -58,6 +58,26 @@ class ApiErrorFormatter { $this->format = $format; } + /** + * Return a formatter like this one but with a different format + * + * @since 1.32 + * @param string $format New format. + * @return ApiErrorFormatter + */ + public function newWithFormat( $format ) { + return new self( $this->result, $this->lang, $format, $this->useDB ); + } + + /** + * Fetch the format for this formatter + * @since 1.32 + * @return string + */ + public function getFormat() { + return $this->format; + } + /** * Fetch the Language for this formatter * @since 1.29 @@ -361,6 +381,10 @@ class ApiErrorFormatter_BackCompat extends ApiErrorFormatter { parent::__construct( $result, Language::factory( 'en' ), 'none', false ); } + public function getFormat() { + return 'bc'; + } + public function arrayFromStatus( StatusValue $status, $type = 'error', $format = null ) { if ( $status->isGood() || !$status->getErrors() ) { return []; diff --git a/includes/api/ApiQueryInfo.php b/includes/api/ApiQueryInfo.php index 3b7b00de80..2ab3c56c64 100644 --- a/includes/api/ApiQueryInfo.php +++ b/includes/api/ApiQueryInfo.php @@ -527,11 +527,27 @@ class ApiQueryInfo extends ApiQueryBase { return null; // force a continuation } + $detailLevel = $this->params['testactionsdetail']; + $rigor = $detailLevel === 'quick' ? 'quick' : 'secure'; + $errorFormatter = $this->getErrorFormatter(); + if ( $errorFormatter->getFormat() === 'bc' ) { + // Eew, no. Use a more modern format here. + $errorFormatter = $errorFormatter->newWithFormat( 'plaintext' ); + } + $user = $this->getUser(); $pageInfo['actions'] = []; foreach ( $this->params['testactions'] as $action ) { $this->countTestedActions++; - $pageInfo['actions'][$action] = $title->userCan( $action, $user ); + + if ( $detailLevel === 'boolean' ) { + $pageInfo['actions'][$action] = $title->userCan( $action, $user ); + } else { + $pageInfo['actions'][$action] = $errorFormatter->arrayFromStatus( $this->errorArrayToStatus( + $title->getUserPermissionsErrors( $action, $user, $rigor ), + $user + ) ); + } } } @@ -955,11 +971,19 @@ class ApiQueryInfo extends ApiQueryBase { // need to be added to getCacheMode() ], ApiBase::PARAM_HELP_MSG_PER_VALUE => [], + ApiBase::PARAM_DEPRECATED_VALUES => [ + 'readable' => true, // Since 1.32 + ], ], 'testactions' => [ ApiBase::PARAM_TYPE => 'string', ApiBase::PARAM_ISMULTI => true, ], + 'testactionsdetail' => [ + ApiBase::PARAM_TYPE => [ 'boolean', 'full', 'quick' ], + ApiBase::PARAM_DFLT => 'boolean', + ApiBase::PARAM_HELP_MSG_PER_VALUE => [], + ], 'token' => [ ApiBase::PARAM_DEPRECATED => true, ApiBase::PARAM_ISMULTI => true, diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 68cc07a030..25bf3f74a7 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -920,11 +920,15 @@ "apihelp-query+info-paramvalue-prop-notificationtimestamp": "The watchlist notification timestamp of each page.", "apihelp-query+info-paramvalue-prop-subjectid": "The page ID of the parent page for each talk page.", "apihelp-query+info-paramvalue-prop-url": "Gives a full URL, an edit URL, and the canonical URL for each page.", - "apihelp-query+info-paramvalue-prop-readable": "Whether the user can read this page.", + "apihelp-query+info-paramvalue-prop-readable": "Whether the user can read this page. Use intestactions=read instead.", "apihelp-query+info-paramvalue-prop-preload": "Gives the text returned by EditFormPreloadText.", "apihelp-query+info-paramvalue-prop-displaytitle": "Gives the manner in which the page title is actually displayed.", "apihelp-query+info-paramvalue-prop-varianttitles": "Gives the display title in all variants of the site content language.", "apihelp-query+info-param-testactions": "Test whether the current user can perform certain actions on the page.", + "apihelp-query+info-param-testactionsdetail": "Detail level for $1testactions. Use the [[Special:ApiHelp/main|main module]]'s errorformat and errorlang parameters to control the format of the messages returned.", + "apihelp-query+info-paramvalue-testactionsdetail-boolean": "Return a boolean value for each action.", + "apihelp-query+info-paramvalue-testactionsdetail-full": "Return messages describing why the action is disallowed, or an empty array if it is allowed.", + "apihelp-query+info-paramvalue-testactionsdetail-quick": "Like full but skipping expensive checks.", "apihelp-query+info-param-token": "Use [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] instead.", "apihelp-query+info-example-simple": "Get information about the page Main Page.", "apihelp-query+info-example-protection": "Get general and protection information about the page Main Page.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index 1cfc683181..d27933051a 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -867,6 +867,10 @@ "apihelp-query+info-paramvalue-prop-displaytitle": "{{doc-apihelp-paramvalue|query+info|prop|displaytitle}}", "apihelp-query+info-paramvalue-prop-varianttitles": "{{doc-apihelp-paramvalue|query+info|prop|varianttitles}}", "apihelp-query+info-param-testactions": "{{doc-apihelp-param|query+info|testactions}}", + "apihelp-query+info-param-testactionsdetail": "{{doc-apihelp-param|query+info|testactionsdetail}}", + "apihelp-query+info-paramvalue-testactionsdetail-boolean": "{{doc-apihelp-paramvalue|query+info|testactionsdetail|boolean}}", + "apihelp-query+info-paramvalue-testactionsdetail-full": "{{doc-apihelp-paramvalue|query+info|testactionsdetail|full}}", + "apihelp-query+info-paramvalue-testactionsdetail-quick": "{{doc-apihelp-paramvalue|query+info|testactionsdetail|quick}}", "apihelp-query+info-param-token": "{{doc-apihelp-param|query+info|token}}", "apihelp-query+info-example-simple": "{{doc-apihelp-example|query+info}}", "apihelp-query+info-example-protection": "{{doc-apihelp-example|query+info}}", diff --git a/tests/phpunit/includes/api/ApiErrorFormatterTest.php b/tests/phpunit/includes/api/ApiErrorFormatterTest.php index 144586e517..d11e3143ea 100644 --- a/tests/phpunit/includes/api/ApiErrorFormatterTest.php +++ b/tests/phpunit/includes/api/ApiErrorFormatterTest.php @@ -14,6 +14,7 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { $result = new ApiResult( 8388608 ); $formatter = new ApiErrorFormatter( $result, Language::factory( 'de' ), 'wikitext', false ); $this->assertSame( 'de', $formatter->getLanguage()->getCode() ); + $this->assertSame( 'wikitext', $formatter->getFormat() ); $formatter->addMessagesFromStatus( null, Status::newGood() ); $this->assertSame( @@ -31,6 +32,25 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { ); } + /** + * @covers ApiErrorFormatter + * @covers ApiErrorFormatter_BackCompat + */ + public function testNewWithFormat() { + $result = new ApiResult( 8388608 ); + $formatter = new ApiErrorFormatter( $result, Language::factory( 'de' ), 'wikitext', false ); + $formatter2 = $formatter->newWithFormat( 'html' ); + + $this->assertSame( $formatter->getLanguage(), $formatter2->getLanguage() ); + $this->assertSame( 'html', $formatter2->getFormat() ); + + $formatter3 = new ApiErrorFormatter_BackCompat( $result ); + $formatter4 = $formatter3->newWithFormat( 'html' ); + $this->assertNotInstanceOf( ApiErrorFormatter_BackCompat::class, $formatter4 ); + $this->assertSame( $formatter3->getLanguage(), $formatter4->getLanguage() ); + $this->assertSame( 'html', $formatter4->getFormat() ); + } + /** * @covers ApiErrorFormatter * @dataProvider provideErrorFormatter @@ -351,6 +371,7 @@ class ApiErrorFormatterTest extends MediaWikiLangTestCase { $formatter = new ApiErrorFormatter_BackCompat( $result ); $this->assertSame( 'en', $formatter->getLanguage()->getCode() ); + $this->assertSame( 'bc', $formatter->getFormat() ); $this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) ); diff --git a/tests/phpunit/includes/api/ApiQueryInfoTest.php b/tests/phpunit/includes/api/ApiQueryInfoTest.php new file mode 100644 index 0000000000..80043daee4 --- /dev/null +++ b/tests/phpunit/includes/api/ApiQueryInfoTest.php @@ -0,0 +1,146 @@ +getExistingTestPage( 'Pluto' ); + $title = $page->getTitle(); + + list( $data ) = $this->doApiRequest( [ + 'action' => 'query', + 'prop' => 'info', + 'titles' => $title->getText(), + ] ); + + $this->assertArrayHasKey( 'query', $data ); + $this->assertArrayHasKey( 'pages', $data['query'] ); + $this->assertArrayHasKey( $page->getId(), $data['query']['pages'] ); + + $info = $data['query']['pages'][$page->getId()]; + $this->assertSame( $page->getId(), $info['pageid'] ); + $this->assertSame( $title->getNamespace(), $info['ns'] ); + $this->assertSame( $title->getText(), $info['title'] ); + $this->assertSame( $title->getContentModel(), $info['contentmodel'] ); + $this->assertSame( $title->getPageLanguage()->getCode(), $info['pagelanguage'] ); + $this->assertSame( $title->getPageLanguage()->getHtmlCode(), $info['pagelanguagehtmlcode'] ); + $this->assertSame( $title->getPageLanguage()->getDir(), $info['pagelanguagedir'] ); + $this->assertSame( wfTimestamp( TS_ISO_8601, $title->getTouched() ), $info['touched'] ); + $this->assertSame( $title->getLatestRevID(), $info['lastrevid'] ); + $this->assertSame( $title->getLength(), $info['length'] ); + $this->assertSame( $title->isNewPage(), $info['new'] ); + $this->assertArrayNotHasKey( 'actions', $info ); + } + + /** + * @covers ::execute + * @covers ::extractPageInfo + */ + public function testExecuteEditActions() { + $page = $this->getExistingTestPage( 'Pluto' ); + $title = $page->getTitle(); + + list( $data ) = $this->doApiRequest( [ + 'action' => 'query', + 'prop' => 'info', + 'titles' => $title->getText(), + 'intestactions' => 'edit' + ] ); + + $this->assertArrayHasKey( 'query', $data ); + $this->assertArrayHasKey( 'pages', $data['query'] ); + $this->assertArrayHasKey( $page->getId(), $data['query']['pages'] ); + + $info = $data['query']['pages'][$page->getId()]; + $this->assertArrayHasKey( 'actions', $info ); + $this->assertArrayHasKey( 'edit', $info['actions'] ); + $this->assertTrue( $info['actions']['edit'] ); + } + + /** + * @covers ::execute + * @covers ::extractPageInfo + */ + public function testExecuteEditActionsFull() { + $page = $this->getExistingTestPage( 'Pluto' ); + $title = $page->getTitle(); + + list( $data ) = $this->doApiRequest( [ + 'action' => 'query', + 'prop' => 'info', + 'titles' => $title->getText(), + 'intestactions' => 'edit', + 'intestactionsdetail' => 'full', + ] ); + + $this->assertArrayHasKey( 'query', $data ); + $this->assertArrayHasKey( 'pages', $data['query'] ); + $this->assertArrayHasKey( $page->getId(), $data['query']['pages'] ); + + $info = $data['query']['pages'][$page->getId()]; + $this->assertArrayHasKey( 'actions', $info ); + $this->assertArrayHasKey( 'edit', $info['actions'] ); + $this->assertInternalType( 'array', $info['actions']['edit'] ); + $this->assertSame( [], $info['actions']['edit'] ); + } + + /** + * @covers ::execute + * @covers ::extractPageInfo + */ + public function testExecuteEditActionsFullBlock() { + $badActor = $this->getTestUser()->getUser(); + $sysop = $this->getTestSysop()->getUser(); + + $block = new \Block( [ + 'address' => $badActor->getName(), + 'user' => $badActor->getId(), + 'by' => $sysop->getId(), + 'expiry' => 'infinity', + 'sitewide' => 0, + 'enableAutoblock' => true, + ] ); + + $block->insert(); + + $page = $this->getExistingTestPage( 'Pluto' ); + $title = $page->getTitle(); + + list( $data ) = $this->doApiRequest( [ + 'action' => 'query', + 'prop' => 'info', + 'titles' => $title->getText(), + 'intestactions' => 'edit', + 'intestactionsdetail' => 'full', + ], null, false, $badActor ); + + $block->delete(); + + $this->assertArrayHasKey( 'query', $data ); + $this->assertArrayHasKey( 'pages', $data['query'] ); + $this->assertArrayHasKey( $page->getId(), $data['query']['pages'] ); + + $info = $data['query']['pages'][$page->getId()]; + $this->assertArrayHasKey( 'actions', $info ); + $this->assertArrayHasKey( 'edit', $info['actions'] ); + $this->assertInternalType( 'array', $info['actions']['edit'] ); + $this->assertArrayHasKey( 0, $info['actions']['edit'] ); + $this->assertArrayHasKey( 'code', $info['actions']['edit'][0] ); + $this->assertSame( 'blocked', $info['actions']['edit'][0]['code'] ); + $this->assertArrayHasKey( 'data', $info['actions']['edit'][0] ); + $this->assertArrayHasKey( 'blockinfo', $info['actions']['edit'][0]['data'] ); + $this->assertArrayHasKey( 'blockid', $info['actions']['edit'][0]['data']['blockinfo'] ); + $this->assertSame( $block->getId(), $info['actions']['edit'][0]['data']['blockinfo']['blockid'] ); + } + +} -- 2.20.1