From 1b03dd0a5292849f38982501215cdfd58e6f6735 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Wed, 2 Sep 2026 21:36:02 -0400 Subject: [PATCH] Key parser-cached output by the parsing user's access class For https://github.com/ProfessionalWiki/NeoWiki/issues/1059 Follows-up to https://github.com/ProfessionalWiki/NeoWiki/pull/1346 With the parse-time gates in place, the parser cache still served whatever the last parse produced to every reader: a sysop's parse cached restricted values for anonymous readers, and an anonymous parse cached gaps for sysops. Such output is now keyed by the parsing user's access class. * A cache-varying parser option, `neowikiAccessClass`, registered through `ParserOptionsRegister`. It carries no value; the class enters the key through the `PageRenderingHash` hook, and only for a page whose parse recorded the option. A lazily valued option would instead be loaded for every logged-in edit of every page by core's cache-key comparison, pushing all of them onto the deferred parser-cache path. * The class itself, from `UserAccessClass`, is the parsing user's effective groups plus the wiki-level `read` and `neowiki-query` decisions, as a readable string such as `*,autoconfirmed,user;read;query`. Group names are encoded, since they reach the class from hooks and the database and could otherwise be named to describe like another set of groups. Every reader gets a class, the anonymous one included, so a page that reads Subjects never reuses an entry cached before this change and no upgrade purge is needed. * Obtaining the parsing authority (`ParserAuthority::of`) records the option, so every gated read keys its page by access class without a call site having to remember to. `{{#view}}` now resolves its page's Main Subject through the public `PageSubjectsLookup` instead of a parsing authority, so it keeps one entry as before. * ADR 27's open decision on parse-time semantics resolves to this rule. The alternatives weighed (a fixed anonymous authority, post-cache trimming, a cache-off operating rule) are recorded under Alternatives Considered. The class is a proxy for the permission hooks: exact wherever page access follows group membership, wrong for hooks that grant per user, which is why such wikis must run with the parser cache off. The installation docs now say so next to the rights. Installs with restricted content need one `refreshLinks` run, noted in the upgrading docs, because MediaWiki rewrites categories and page properties only on an edit. Not solved here, and now stated in ADR 27's consequences: data derived from the canonical parse (categories, page properties, links tables, the Page node's categories in graph projections) is computed as the anonymous user, so on a wiki where anonymous users cannot read, a category derived from a parse-time read is never set. A designated reader for canonical parses would lift that; it needs a decision. ## Manual Browser Check 1. With the parser cache enabled, restrict a page's read permission for anonymous users (for example with the Lockdown extension) and give it a Subject with a text property. 2. On a second page, save `{{#neowiki_value: | page=}}` as a sysop and view it: the value shows. 3. View that page logged out, without purging: the value is absent. Log back in and view it again: the value shows. Each access class keeps its own cached copy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YDxvurgMTmC6JieEVYRzq8 --- docs/adr/027-access-control.md | 43 +++- docs/authoring/parser-functions.md | 4 +- docs/operations/installation.md | 4 + docs/operations/upgrading.md | 4 + extension.json | 2 + src/EntryPoints/NeoWikiHooks.php | 21 +- src/EntryPoints/ParserAuthority.php | 44 ++++ .../Scribunto/ScribuntoLuaLibrary.php | 3 +- src/EntryPoints/ViewParserFunction.php | 21 +- .../Neo4j/Neo4jPlugin.php | 2 +- .../Sparql/SparqlPlugin.php | 2 +- src/Infrastructure/UserAccessClass.php | 43 ++++ src/NeoWikiExtension.php | 8 + .../EntryPoints/ParserAuthorityTest.php | 189 ++++++++++++++++++ .../EntryPoints/ViewParserFunctionTest.php | 36 +++- .../Infrastructure/UserAccessClassTest.php | 101 ++++++++++ 16 files changed, 496 insertions(+), 31 deletions(-) create mode 100644 src/Infrastructure/UserAccessClass.php create mode 100644 tests/phpunit/EntryPoints/ParserAuthorityTest.php create mode 100644 tests/phpunit/Infrastructure/UserAccessClassTest.php diff --git a/docs/adr/027-access-control.md b/docs/adr/027-access-control.md index 75e1b3b55..cab4b7607 100644 --- a/docs/adr/027-access-control.md +++ b/docs/adr/027-access-control.md @@ -53,6 +53,17 @@ Constraints the model rests on: by the wiki-level `neowiki-query` right; granting that right gives read access to everything the wiki projects into the store. Exposing a store directly (which ADR 19 allows for SPARQL) is a different surface: see the projection decision below. +- **Parse-time reads run as the user the page is parsed for, and their output is cached per access class.** + The parser functions and the Lua library read as the user recorded in the parser options: the viewer on a + page view, or the anonymous user for the save-time parse, the job queue, and Parsoid renders. They are gated + like the REST endpoints: page `read` for page-attributable reads, `neowiki-query` for raw queries. Query + limits use the default tier regardless of user. Output that depends on such a read is parser-cached under + the parsing user's access class, derived from the user's effective groups and the wiki-level `read` and + `neowiki-query` grants, so a cached copy is shared only within one class. The class is a proxy for the + permission hooks: exact wherever page access follows group membership, wrong for hooks that grant per + user (the revision-deletion rights included), so wikis with such hooks must run without a parser cache. + `{{#view}}` needs no class: it emits a marker at parse time, and the Subject behind it is fetched per + viewer over REST, under that viewer's permissions. - **Raw queries will support server-side filter injection.** A deployment can register scoping predicates (such as restricting to the current wiki) that core applies to every caller-supplied query, so scoping is enforced rather than left to each caller. @@ -64,13 +75,6 @@ Constraints the model rests on: These decisions remain open at acceptance; each is deferred to the tracking issue named with it. -- **Parse-time read semantics.** Today the parse path is inconsistent - ([#1059](https://github.com/ProfessionalWiki/NeoWiki/issues/1059)): Schema lookups are gated per user but their - output is parser-cached user-agnostically; subject accessors (`{{#neowiki_value}}` and the `nw` data accessors) - check only revision-deletion visibility, not page `read`; `{{#cypher_raw}}`, `{{#sparql_raw}}`, `nw.query` and - `nw.sparqlQuery` check nothing. `{{#view}}` is the leak-free pattern: a placeholder rendered at parse time, data - fetched per user over REST. Deferred to [#1059](https://github.com/ProfessionalWiki/NeoWiki/issues/1059): the - parse-path rule and what it means for each surface. - **Cross-wiki subject display.** Rendering a subject from another wiki goes through REST, not Cypher, so query-side scoping does not cover it. Deferred to [#1341](https://github.com/ProfessionalWiki/NeoWiki/issues/1341): the check and the degradation behavior when the schema or subject is not accessible. Relates to @@ -87,8 +91,22 @@ These decisions remain open at acceptance; each is deferred to the tracking issu ## Consequences - Every new surface that exposes NeoWiki data must be classified: page-attributable (per-row gate), raw query - (whole-store semantics), projection/dump (no permission checks), or parse-time (pending above). There is no - unclassified option. + (whole-store semantics), projection/dump (no permission checks), or parse-time (parsing user's authority, + output keyed by access class). There is no unclassified option. +- A page that reads Subjects or runs queries at parse time holds one parser-cache entry per access class + among its viewers. Current-revision views of other pages are unaffected; old-revision views are keyed per + class wiki-wide, because core's revision-output cache keys on every cache-varying option rather than the + ones a page used. Saving such a page parses it twice when the editor is logged in: once canonically, once + for the editor's class, as pages using `{{int:}}` already do for editors with a non-default interface + language. +- Restricting content does not invalidate what is already cached: the class describes the reader, not the + page, so a page that embeds newly restricted data keeps serving it to its class until the page is edited + or purged, or `$wgParserCacheExpireTime` elapses. +- Data derived from the canonical parse is computed as the anonymous user: the categories, page properties and + links tables written on save and by jobs, and the categories and parser properties of the Page node in graph + projections. On a wiki where anonymous users cannot read, a category or page property derived from a + parse-time read is therefore never set. A designated reader for canonical parses would lift this; it is not + decided. - Restricting a page does not remove its data from stores; it changes what the backend returns. - The filter-injection extension point must be designed and implemented for farms like BlueSpice Galaxy. - Dumps and projections contain restricted content (unless it is omitted via a non-permission mechanism such as the @@ -100,6 +118,13 @@ These decisions remain open at acceptance; each is deferred to the tracking issu QLever, and unable to express hook-based MediaWiki permissions. Rejected, consistent with ADR 13. - **Project ACL state into the graph for pre-query trimming** (user groups, restriction markers): re-implements an open set of permission hooks as data and goes stale, because permission changes produce no revision to sync on. +- **Evaluate parse-time reads as a fixed anonymous authority**: user-independent output by construction, but + every privileged reader sees less than they may read, and on a private wiki the functions show nothing. +- **Cache the superset and trim per viewer after the cache**, as core does for section edit links: sound for opaque + display fragments, but the cache then holds restricted data for every consumer that bypasses the output pipeline, + and an ungated parse leaks through categories, page properties and Lua control flow, which no HTML trim retracts. +- **Leave the cache alone and require wikis with restricted content to disable it**: no machinery, but nothing + detects a violation, and a privileged first parse silently caches restricted values for everyone. ## Related diff --git a/docs/authoring/parser-functions.md b/docs/authoring/parser-functions.md index f6bce1a47..4b415cad1 100644 --- a/docs/authoring/parser-functions.md +++ b/docs/authoring/parser-functions.md @@ -22,7 +22,9 @@ For definitions of terms like Subject, Schema, and Layout, see the [Glossary](.. Every parser function reads as the user the page is parsed for. Subjects that user cannot read are treated as absent. `{{#cypher_raw}}` and `{{#sparql_raw}}` need the `neowiki-query` right. `{{#view}}` only places a marker at parse time; the Subject it shows is fetched per viewer over the -REST API, under that viewer's permissions. +REST API, under that viewer's permissions. Output of the other functions is cached separately for +readers with different groups or rights, so what one reader may see does not reach another through +the parser cache. ## `{{#view}}` diff --git a/docs/operations/installation.md b/docs/operations/installation.md index 5a9d7c7f2..d84c2785f 100644 --- a/docs/operations/installation.md +++ b/docs/operations/installation.md @@ -195,6 +195,10 @@ default. `neowiki-schema-edit`, `neowiki-layout-edit` and `neowiki-mapping-edit` — gate editing in NeoWiki's own namespaces and are granted to logged-in users. +Parser functions and Lua read as the user the page is parsed for, and their output is parser-cached per combination +of user groups and wiki-level rights. A permission extension that grants page access per user rather than per group +is not followed by that cache key: run such a wiki with the parser cache off (`$wgParserCacheType = CACHE_NONE`). + ## On-wiki configuration A wiki administrator without server access can set part of NeoWiki's configuration on the `MediaWiki:NeoWiki` diff --git a/docs/operations/upgrading.md b/docs/operations/upgrading.md index c98dd0ca3..8952f46c6 100644 --- a/docs/operations/upgrading.md +++ b/docs/operations/upgrading.md @@ -55,5 +55,9 @@ php maintenance/run.php NeoWiki:RebuildGraphDatabases [Rebuild](maintenance.md#rebuilding-the-graph) after every upgrade: with no release notes there is no way to tell whether the new version changed the projected shape, and rebuilds are quick at evaluation scale. +If your install predates September 2026 and holds restricted content, run `php maintenance/run.php refreshLinks` +once: categories and page properties that earlier parses derived from Subject data were recorded without a +permission check, and MediaWiki rewrites those tables only on an edit, not on a view. + If your Subjects predate the optional Subject label, run [clearing default Subject labels](maintenance.md#clearing-default-subject-labels) once, before that rebuild. diff --git a/extension.json b/extension.json index 5cc882981..c404bad7c 100644 --- a/extension.json +++ b/extension.json @@ -44,6 +44,8 @@ "LoadExtensionSchemaUpdates": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onLoadExtensionSchemaUpdates", + "ParserOptionsRegister": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onParserOptionsRegister", + "PageRenderingHash": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onPageRenderingHash", "ParserFirstCallInit": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onParserFirstCallInit", "RevisionFromEditComplete": "ProfessionalWiki\\NeoWiki\\EntryPoints\\NeoWikiHooks::onRevisionFromEditComplete", diff --git a/src/EntryPoints/NeoWikiHooks.php b/src/EntryPoints/NeoWikiHooks.php index bc0569eaf..6019a3a67 100644 --- a/src/EntryPoints/NeoWikiHooks.php +++ b/src/EntryPoints/NeoWikiHooks.php @@ -21,6 +21,7 @@ use MediaWiki\Revision\SlotRoleRegistry; use MediaWiki\Title\ForeignTitle; use MediaWiki\Title\Title; +use MediaWiki\User\User; use MediaWiki\User\UserIdentity; use MessageLocalizer; use ProfessionalWiki\NeoWiki\Application\Rdf\RdfPageProjector; @@ -251,6 +252,22 @@ private static function reportFailedGraphDatabaseInitialization( DatabaseUpdater ); } + /** + * @param array $defaults + * @param array $inCacheKey + * @param array $lazyOptions + */ + public static function onParserOptionsRegister( array &$defaults, array &$inCacheKey, array &$lazyOptions ): void { + ParserAuthority::registerAccessClassOption( $defaults, $inCacheKey ); + } + + /** + * @param string[] $forOptions + */ + public static function onPageRenderingHash( string &$confstr, User $user, array &$forOptions ): void { + ParserAuthority::appendAccessClassToRenderingHash( $confstr, $user, $forOptions ); + } + public static function onParserFirstCallInit( Parser $parser ): void { NeoWikiExtension::getInstance()->getNeo4jPlugin()?->registerParserFunctions( $parser ); NeoWikiExtension::getInstance()->getFirstSparqlPlugin()?->registerParserFunctions( $parser ); @@ -258,9 +275,7 @@ public static function onParserFirstCallInit( Parser $parser ): void { $parser->setFunctionHook( 'view', static function ( Parser $parser, string ...$args ): string|array { - $parserFunction = new ViewParserFunction( - NeoWikiExtension::getInstance()->newSubjectContentRepository( ParserAuthority::of( $parser ) ) - ); + $parserFunction = new ViewParserFunction( NeoWikiExtension::getInstance()->newPageSubjectsLookup() ); return $parserFunction->handle( $parser, ...$args ); } ); diff --git a/src/EntryPoints/ParserAuthority.php b/src/EntryPoints/ParserAuthority.php index bc7263d41..aed8e9cb6 100644 --- a/src/EntryPoints/ParserAuthority.php +++ b/src/EntryPoints/ParserAuthority.php @@ -7,6 +7,9 @@ use MediaWiki\MediaWikiServices; use MediaWiki\Parser\Parser; use MediaWiki\Permissions\Authority; +use MediaWiki\User\UserIdentity; +use ProfessionalWiki\NeoWiki\Infrastructure\UserAccessClass; +use ProfessionalWiki\NeoWiki\NeoWikiExtension; /** * The authority every parse-time read runs against: the user the page is being parsed for. That is @@ -15,11 +18,52 @@ * context's user is the wrong choice here: it is the saver during the canonical parse of an edit, and * whoever runs the job queue otherwise, neither of which matches the identity the parser cache files * the output under. + * + * Obtaining the authority is also what makes the parse's output depend on who that user is, so + * {@see self::of()} records the access-class parser option as used: the parser cache then files the + * output under the user's access class ({@see UserAccessClass}) instead of sharing it across users. + * Pages that never obtain a parsing authority keep one cache entry for everyone. + * + * The option itself carries no value. Its class enters the cache key through the PageRenderingHash + * hook, and only for a page that recorded the option: a lazily valued option would be loaded for + * every logged-in edit of every page by core's cache-key comparison, sending all of them down the + * deferred parser-cache path. */ class ParserAuthority { + public const string ACCESS_CLASS_OPTION = 'neowikiAccessClass'; + public static function of( Parser $parser ): Authority { + $parser->getOptions()->getOption( self::ACCESS_CLASS_OPTION ); + return MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $parser->getUserIdentity() ); } + /** + * The ParserOptionsRegister hook body. + * + * @param array $defaults + * @param array $inCacheKey + */ + public static function registerAccessClassOption( array &$defaults, array &$inCacheKey ): void { + $defaults[self::ACCESS_CLASS_OPTION] = null; + $inCacheKey[self::ACCESS_CLASS_OPTION] = true; + } + + /** + * The PageRenderingHash hook body: the parsing user's access class, for a page that recorded the + * option. Every reader gets one, so a page that reads Subjects also stops reusing whatever was + * cached for it before NeoWiki began keying by class. + * + * @param string[] $usedOptions + */ + public static function appendAccessClassToRenderingHash( string &$hash, UserIdentity $user, array $usedOptions ): void { + if ( !in_array( self::ACCESS_CLASS_OPTION, $usedOptions, true ) ) { + return; + } + + $hash .= '!' . self::ACCESS_CLASS_OPTION . '=' + . NeoWikiExtension::getInstance()->newUserAccessClass()->of( $user ); + } + } diff --git a/src/EntryPoints/Scribunto/ScribuntoLuaLibrary.php b/src/EntryPoints/Scribunto/ScribuntoLuaLibrary.php index b7a3ca824..13935e718 100644 --- a/src/EntryPoints/Scribunto/ScribuntoLuaLibrary.php +++ b/src/EntryPoints/Scribunto/ScribuntoLuaLibrary.php @@ -63,7 +63,8 @@ private function getCypherQueryRunner(): CypherQueryRunner { /** * Every read this library performs runs against the user the page is parsed for - * ({@see ParserAuthority}), so a module cannot read more than the reader may. + * ({@see ParserAuthority}), so a module cannot read more than the reader may, and the parse + * is keyed by that user's access class in the parser cache. */ private function getParserAuthority(): Authority { return ParserAuthority::of( $this->getParser() ); diff --git a/src/EntryPoints/ViewParserFunction.php b/src/EntryPoints/ViewParserFunction.php index 2ec611bc8..c78012e7e 100644 --- a/src/EntryPoints/ViewParserFunction.php +++ b/src/EntryPoints/ViewParserFunction.php @@ -5,7 +5,8 @@ namespace ProfessionalWiki\NeoWiki\EntryPoints; use MediaWiki\Parser\Parser; -use ProfessionalWiki\NeoWiki\Application\SubjectContentRepository; +use ProfessionalWiki\NeoWiki\Application\PageSubjectsLookup; +use ProfessionalWiki\NeoWiki\Domain\Page\PageId; use ProfessionalWiki\NeoWiki\Presentation\ViewHtmlBuilder; class ViewParserFunction { @@ -13,8 +14,13 @@ class ViewParserFunction { private const string ARG_SUBJECT = 'subject'; private const string ARG_LAYOUT = 'layout'; + /** + * Emits a marker that the frontend fills per viewer over the REST API, so the parse-time work is + * the same for every reader: the page's Main Subject id is read publicly, not as a parsing + * authority ({@see ParserAuthority}), and the output stays out of the per-class cache keying. + */ public function __construct( - private readonly SubjectContentRepository $subjectContentRepository + private readonly PageSubjectsLookup $pageSubjectsLookup ) { } @@ -138,12 +144,13 @@ private function resolveMainSubjectId( Parser $parser ): ?string { return null; } - $subject = $this->subjectContentRepository - ->getSubjectContentByPageTitle( $title ) - ?->getPageSubjects() - ->getMainSubject(); + $pageId = $title->getArticleID(); + + if ( $pageId === 0 ) { + return null; + } - return $subject?->getId()->text; + return $this->pageSubjectsLookup->getMainSubjectId( new PageId( $pageId ) )?->text; } } diff --git a/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php b/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php index f4077c6c3..0fd74c41d 100644 --- a/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php +++ b/src/GraphDatabasePlugins/Neo4j/Neo4jPlugin.php @@ -113,7 +113,7 @@ public function newLuaQueryRunner( Parser $parser ): CypherQueryRunner { /** * Parse-time queries run as the user the page is parsed for, and always at the default tier: the - * output is parser-cached, so neither may depend on who happened to parse. + * output is parser-cached under that user's access class, and may vary by nothing else. */ private function newParseTimeQueryService( Parser $parser ): Neo4jQueryService { return $this->newQueryService( ParserAuthority::of( $parser ) ); diff --git a/src/GraphDatabasePlugins/Sparql/SparqlPlugin.php b/src/GraphDatabasePlugins/Sparql/SparqlPlugin.php index 8016fdcf1..defefd88f 100644 --- a/src/GraphDatabasePlugins/Sparql/SparqlPlugin.php +++ b/src/GraphDatabasePlugins/Sparql/SparqlPlugin.php @@ -103,7 +103,7 @@ public function newLuaQueryRunner( Parser $parser ): SparqlQueryRunner { /** * Parse-time queries run as the user the page is parsed for, and always at the default tier: the - * output is parser-cached, so neither may depend on who happened to parse. + * output is parser-cached under that user's access class, and may vary by nothing else. */ private function newParseTimeQueryService( Parser $parser ): SparqlQueryService { return $this->newQueryService( ParserAuthority::of( $parser ) ); diff --git a/src/Infrastructure/UserAccessClass.php b/src/Infrastructure/UserAccessClass.php new file mode 100644 index 000000000..52820014e --- /dev/null +++ b/src/Infrastructure/UserAccessClass.php @@ -0,0 +1,43 @@ +userGroupManager->getUserEffectiveGroups( $user ); + sort( $groups ); + + // Group names come from hooks and the database, so they can hold the separators, and the + // cache key turns spaces into underscores. Without encoding, a user in a group named + // "sysop,user" would describe exactly like a sysop and read that class's cached output. + return implode( ',', array_map( 'rawurlencode', $groups ) ) + . ( $this->permissionManager->userHasRight( $user, 'read' ) ? ';read' : '' ) + . ( $this->permissionManager->userHasRight( $user, AuthorityBasedRawQueryAuthorizer::RIGHT ) ? ';query' : '' ); + } + +} diff --git a/src/NeoWikiExtension.php b/src/NeoWikiExtension.php index 0a8d36855..2f064740c 100644 --- a/src/NeoWikiExtension.php +++ b/src/NeoWikiExtension.php @@ -55,6 +55,7 @@ use ProfessionalWiki\NeoWiki\GraphDatabasePlugins\Sparql\EntryPoints\Lua\SparqlQueryRunner; use ProfessionalWiki\NeoWiki\Infrastructure\IdGenerator; use ProfessionalWiki\NeoWiki\Infrastructure\ProductionIdGenerator; +use ProfessionalWiki\NeoWiki\Infrastructure\UserAccessClass; use ProfessionalWiki\NeoWiki\Persistence\CorePagePropertyProvider; use ProfessionalWiki\NeoWiki\Application\EditNotice\InterfaceMessageNoticeProvider; use ProfessionalWiki\NeoWiki\Application\Queries\GetSubjectEditNotices\GetSubjectEditNoticesPresenter; @@ -1283,6 +1284,13 @@ public function newSubjectWriteAuthorizer( Authority $authority ): SubjectWriteA return $this->newAuthorityBasedSubjectAuthorizer( $authority ); } + public function newUserAccessClass(): UserAccessClass { + return new UserAccessClass( + userGroupManager: MediaWikiServices::getInstance()->getUserGroupManager(), + permissionManager: MediaWikiServices::getInstance()->getPermissionManager(), + ); + } + public function newPageReadAuthorizer( Authority $authority ): PageReadAuthorizer { return new AuthorityBasedPageReadAuthorizer( authority: $authority, diff --git a/tests/phpunit/EntryPoints/ParserAuthorityTest.php b/tests/phpunit/EntryPoints/ParserAuthorityTest.php new file mode 100644 index 000000000..9c7c1cdc0 --- /dev/null +++ b/tests/phpunit/EntryPoints/ParserAuthorityTest.php @@ -0,0 +1,189 @@ +createPageWithSubjects( + self::SUBJECT_PAGE, + TestSubject::build( statements: new StatementList( [ + new Statement( new PropertyName( 'Motto' ), 'text', new StringValue( 'Cached per class' ) ), + ] ) ) + ); + } + + private function sysopOptions(): ParserOptions { + return ParserOptions::newFromUser( $this->getTestSysop()->getUser() ); + } + + private function parse( + string $wikitext, + ParserOptions $parserOptions, + string $pageName = 'ParserAuthorityTestPage' + ): ParserOutput { + return $this->getServiceContainer()->getParserFactory()->create()->parse( + $wikitext, + Title::newFromText( $pageName ), + $parserOptions + ); + } + + /** + * Entries cached before NeoWiki keyed by access class carry no such key, so a gated page must not + * land on the key those entries used, not even for an anonymous reader. + */ + public function testAGatedPageDoesNotReuseAnEntryCachedWithoutTheAccessClass(): void { + $anonymousOptions = ParserOptions::newFromAnon(); + + $this->assertNotSame( + $anonymousOptions->optionsHash( [] ), + $anonymousOptions->optionsHash( [ ParserAuthority::ACCESS_CLASS_OPTION ] ) + ); + } + + /** + * Core compares every cache-varying option of the editor's options against the canonical ones on + * save, and defers the parser-cache update for every page when they differ. + */ + public function testEditorsOptionsStillMatchTheCanonicalOnesForPagesWithoutGatedReads(): void { + $this->assertTrue( $this->sysopOptions()->matchesForCacheKey( ParserOptions::newFromAnon() ) ); + } + + public function testParseForALoggedInUserHasItsOwnCacheKey(): void { + $this->assertNotSame( + ParserOptions::newFromAnon()->optionsHash( [ ParserAuthority::ACCESS_CLASS_OPTION ] ), + $this->sysopOptions()->optionsHash( [ ParserAuthority::ACCESS_CLASS_OPTION ] ) + ); + } + + public function testReadingASubjectPutsTheAccessClassInThePagesCacheKey(): void { + $output = $this->parse( self::VALUE_FUNCTION, ParserOptions::newFromAnon() ); + + $this->assertContains( ParserAuthority::ACCESS_CLASS_OPTION, $output->getUsedOptions() ); + } + + /** + * The argument-less view reads its own page's Main Subject at parse time, and still only emits a marker. + */ + public function testRenderingAViewDoesNotFragmentTheCache(): void { + $output = $this->parse( '{{#view}}', ParserOptions::newFromAnon(), self::SUBJECT_PAGE ); + + $this->assertNotContains( ParserAuthority::ACCESS_CLASS_OPTION, $output->getUsedOptions() ); + } + + public function testRunningACypherQueryPutsTheAccessClassInThePagesCacheKey(): void { + $this->grantTheQueryRightToSysopsOnly(); + + $output = $this->parse( '{{#cypher_raw: RETURN 1 AS n }}', ParserOptions::newFromAnon() ); + + $this->assertContains( ParserAuthority::ACCESS_CLASS_OPTION, $output->getUsedOptions() ); + } + + public function testRunningASparqlQueryPutsTheAccessClassInThePagesCacheKey(): void { + $this->configureAnUnreachableSparqlStore(); + $this->grantTheQueryRightToSysopsOnly(); + + $output = $this->parse( '{{#sparql_raw: SELECT * WHERE { ?s ?p ?o } }}', ParserOptions::newFromAnon() ); + + $this->assertContains( ParserAuthority::ACCESS_CLASS_OPTION, $output->getUsedOptions() ); + } + + public function testReadingASubjectFromLuaPutsTheAccessClassInThePagesCacheKey(): void { + $this->markTestSkippedIfExtensionNotLoaded( 'Scribunto' ); + $this->editPage( + Title::makeTitle( NS_MODULE, 'ParserAuthorityTest' ), + new ScribuntoContent( + "local nw = require( 'mw.neowiki' )\n" . + "local p = {}\n" . + "function p.motto( frame ) return nw.getValue( 'Motto', { page = frame.args[1] } ) or '' end\n" . + "return p" + ) + ); + + $output = $this->parse( + '{{#invoke:ParserAuthorityTest|motto|' . self::SUBJECT_PAGE . '}}', + ParserOptions::newFromAnon() + ); + + $this->assertContains( ParserAuthority::ACCESS_CLASS_OPTION, $output->getUsedOptions() ); + } + + /** + * Wikitext holding a gated read, plus a Subject slot of its own, so the render combines two slots + * as every NeoWiki page does: the option the read records must survive that merge to reach the + * cache key. + */ + private function cachedGatedPage( ParserOptions $parserOptions ): WikiPage { + $page = $this->getServiceContainer()->getWikiPageFactory() + ->newFromTitle( Title::makeTitle( NS_MAIN, 'ParserAuthorityTestCachedPage' ) ); + + $updater = $page->newPageUpdater( $this->getTestSysop()->getUser() ); + $updater->setContent( 'main', new WikitextContent( self::VALUE_FUNCTION ) ); + $updater->setContent( + MediaWikiSubjectRepository::SLOT_NAME, + SubjectContent::newFromData( + new PageSubjects( TestSubject::build( id: 's1cached1111111' ), new SubjectMap() ) + ) + ); + $updater->saveRevision( CommentStoreComment::newUnsavedComment( 'Gated page' ) ); + + $this->getServiceContainer()->getParserOutputAccess()->getParserOutput( $page, $parserOptions ); + + return $page; + } + + public function testCachedOutputOfOneClassIsNotServedToAnother(): void { + $anonymousOptions = ParserOptions::newFromAnon(); + $page = $this->cachedGatedPage( $anonymousOptions ); + + $parserCache = $this->getServiceContainer()->getParserCache(); + + $this->assertNotFalse( $parserCache->get( $page, $anonymousOptions ), 'the anonymous parse is cached' ); + $this->assertFalse( $parserCache->get( $page, $this->sysopOptions() ), 'a sysop does not get it' ); + } + + public function testCachedOutputIsSharedWithinAnAccessClass(): void { + $page = $this->cachedGatedPage( ParserOptions::newFromUser( $this->getTestUser( [ 'bot' ] )->getUser() ) ); + + $anotherBot = ParserOptions::newFromUser( $this->getMutableTestUser( [ 'bot' ] )->getUser() ); + + $this->assertNotFalse( $this->getServiceContainer()->getParserCache()->get( $page, $anotherBot ) ); + } + +} diff --git a/tests/phpunit/EntryPoints/ViewParserFunctionTest.php b/tests/phpunit/EntryPoints/ViewParserFunctionTest.php index ee15062ce..903d68324 100644 --- a/tests/phpunit/EntryPoints/ViewParserFunctionTest.php +++ b/tests/phpunit/EntryPoints/ViewParserFunctionTest.php @@ -8,6 +8,8 @@ use MediaWiki\Parser\Parser; use MediaWiki\Title\Title; use PHPUnit\Framework\TestCase; +use ProfessionalWiki\NeoWiki\Application\PageSubjectsLookup; +use ProfessionalWiki\NeoWiki\Domain\Page\PageId; use ProfessionalWiki\NeoWiki\Domain\Page\PageSubjects; use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName; use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList; @@ -16,7 +18,7 @@ use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel; use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectMap; use ProfessionalWiki\NeoWiki\EntryPoints\ViewParserFunction; -use ProfessionalWiki\NeoWiki\Tests\TestDoubles\InMemorySubjectContentRepository; +use ProfessionalWiki\NeoWiki\Tests\TestDoubles\InMemorySubjectRepository; /** * @covers \ProfessionalWiki\NeoWiki\EntryPoints\ViewParserFunction @@ -26,6 +28,7 @@ class ViewParserFunctionTest extends TestCase { private const string MAIN_SUBJECT_ID = 's11111111111111'; private const string EXPLICIT_SUBJECT_ID = 's22222222222222'; private const string OTHER_SUBJECT_ID = 's33333333333333'; + private const int PAGE_ID = 7; public function testEmitsPlaceholderWithExplicitPositionalSubject(): void { $result = $this->callView( self::EXPLICIT_SUBJECT_ID ); @@ -82,17 +85,26 @@ public function testFallsBackToMainSubjectWhenNoArgs(): void { } public function testReturnsEmptyStringWhenNoSubjectAvailable(): void { - $parserFunction = new ViewParserFunction( new InMemorySubjectContentRepository() ); + $parserFunction = new ViewParserFunction( new PageSubjectsLookup( new InMemorySubjectRepository() ) ); $result = $parserFunction->handle( $this->createMockParser() ); $this->assertSame( '', $result ); } + public function testReturnsEmptyStringWhileThePageIsBeingCreated(): void { + $unsavedPage = $this->createStub( Title::class ); + $unsavedPage->method( 'getArticleID' )->willReturn( 0 ); + $parser = $this->createStub( Parser::class ); + $parser->method( 'getTitle' )->willReturn( $unsavedPage ); + + $result = ( new ViewParserFunction( $this->lookupWithMainSubject() ) )->handle( $parser ); + + $this->assertSame( '', $result ); + } + public function testReturnsEmptyStringWhenPageHasNoMainSubject(): void { - $parserFunction = new ViewParserFunction( - new InMemorySubjectContentRepository( new PageSubjects( null, new SubjectMap() ) ) - ); + $parserFunction = new ViewParserFunction( $this->lookupWithPageSubjects( new PageSubjects( null, new SubjectMap() ) ) ); $result = $parserFunction->handle( $this->createMockParser() ); @@ -130,11 +142,18 @@ public function testRendersErrorOnArgWithEmptyName(): void { } private function callView( string ...$args ): string|array { - return ( new ViewParserFunction( $this->repositoryWithMainSubject() ) ) + return ( new ViewParserFunction( $this->lookupWithMainSubject() ) ) ->handle( $this->createMockParser(), ...$args ); } - private function repositoryWithMainSubject(): InMemorySubjectContentRepository { + private function lookupWithPageSubjects( PageSubjects $pageSubjects ): PageSubjectsLookup { + $repository = new InMemorySubjectRepository(); + $repository->savePageSubjects( $pageSubjects, new PageId( self::PAGE_ID ) ); + + return new PageSubjectsLookup( $repository ); + } + + private function lookupWithMainSubject(): PageSubjectsLookup { $mainSubject = new Subject( id: new SubjectId( self::MAIN_SUBJECT_ID ), label: new SubjectLabel( 'Main' ), @@ -142,11 +161,12 @@ private function repositoryWithMainSubject(): InMemorySubjectContentRepository { statements: new StatementList(), ); - return new InMemorySubjectContentRepository( new PageSubjects( $mainSubject, new SubjectMap() ) ); + return $this->lookupWithPageSubjects( new PageSubjects( $mainSubject, new SubjectMap() ) ); } private function createMockParser(): Parser { $title = $this->createStub( Title::class ); + $title->method( 'getArticleID' )->willReturn( self::PAGE_ID ); $parser = $this->createStub( Parser::class ); $parser->method( 'getTitle' )->willReturn( $title ); diff --git a/tests/phpunit/Infrastructure/UserAccessClassTest.php b/tests/phpunit/Infrastructure/UserAccessClassTest.php new file mode 100644 index 000000000..fa247d83d --- /dev/null +++ b/tests/phpunit/Infrastructure/UserAccessClassTest.php @@ -0,0 +1,101 @@ +newUserAccessClass(); + } + + public function testAnonymousAndLoggedInReadersAreDifferentClasses(): void { + $accessClass = $this->newAccessClass(); + + $this->assertNotSame( + $accessClass->of( $this->getServiceContainer()->getUserFactory()->newAnonymous() ), + $accessClass->of( $this->getTestUser()->getUser() ) + ); + } + + /** + * Group names reach the class from hooks and the database, so a group whose name holds a separator + * must not be able to describe like a different set of groups. + */ + public function testAGroupNamedAfterOtherGroupsDoesNotShareTheirClass(): void { + $impostor = $this->getMutableTestUser( [ 'sysop,user' ] )->getUser(); + + $accessClass = $this->newAccessClass(); + + $this->assertNotSame( + $accessClass->of( $this->getTestSysop()->getUser() ), + $accessClass->of( $impostor ) + ); + } + + public function testUsersWithTheSameGroupsShareAClass(): void { + $accessClass = $this->newAccessClass(); + + $this->assertSame( + $accessClass->of( $this->getTestUser( [ 'bot' ] )->getUser() ), + $accessClass->of( $this->getMutableTestUser( [ 'bot' ] )->getUser() ) + ); + } + + public function testUsersWithDifferentGroupsHaveDifferentClasses(): void { + $accessClass = $this->newAccessClass(); + + $this->assertNotSame( + $accessClass->of( $this->getTestUser()->getUser() ), + $accessClass->of( $this->getTestSysop()->getUser() ) + ); + } + + /** + * Installed before the first class is computed: the permission manager memoizes rights per user. + */ + private function grantRightOutsideGroups( User $grantee, string $right ): void { + $this->setTemporaryHook( + 'UserGetRights', + static function ( User $user, array &$rights ) use ( $grantee, $right ): void { + if ( $user->equals( $grantee ) ) { + $rights[] = $right; + } + } + ); + } + + public function testTheQueryRightSeparatesUsersInTheSameGroups(): void { + $this->setGroupPermissions( '*', 'neowiki-query', false ); + $granted = $this->getTestUser()->getUser(); + $other = $this->getMutableTestUser()->getUser(); + $this->grantRightOutsideGroups( $granted, 'neowiki-query' ); + + $accessClass = $this->newAccessClass(); + + $this->assertNotSame( $accessClass->of( $granted ), $accessClass->of( $other ) ); + } + + public function testTheReadRightSeparatesUsersInTheSameGroups(): void { + $this->setGroupPermissions( '*', 'read', false ); + $this->setGroupPermissions( 'user', 'read', false ); + $granted = $this->getTestUser()->getUser(); + $other = $this->getMutableTestUser()->getUser(); + $this->grantRightOutsideGroups( $granted, 'read' ); + + $accessClass = $this->newAccessClass(); + + $this->assertNotSame( $accessClass->of( $granted ), $accessClass->of( $other ) ); + } + +}