Scope CMS lookups to the store and let a store override a shared page - #773
Merged
Conversation
The five CMS reads that take a store took it as `string storeId = ""`, and an omitted argument is not a compile error - it silently turns the tenant filter off. Four callers had omitted it, and the compiler could not say so. Three of them were defects: - The storefront contact page resolved "ContactUs" without a store, so a closed store could be judged by another store's page (both the GET and the POST path). - The store panel dashboard resolved "StorePortalInfo" without a store, showing whichever page matched first rather than the one belonging to the store being administered. - The vendor panel dashboard did the same with "VendorPortalInfo". All three now pass the current store. Pages that are not limited to any store keep resolving everywhere, so what an existing installation can see only narrows where a page was explicitly limited to a different store - which is the leak being closed. The fourth caller, the admin panel's global search, is correct as it stands and now says so with an explicit `storeId: ""`. The admin panel is global by design; writing the empty store where it is genuinely meant is the point of removing the default, not a formality applied everywhere. Two tests pin both directions of the page lookup: a page limited to another store is not returned, and a page limited to nothing still is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The store panel's COPY button never copied anything. Its guard called AccessToEntityByStore, which demands that the page belong to this store and to no other - the exact condition under which there is nothing left to copy. The button only shows when the page is shared, so the guard and the button were mutually exclusive and the whole action was dead code that redirected to the list. Fixing the button exposed the reason it went unnoticed: a copy would have been inert anyway. GetPageBySystemName and GetMessageTemplateByName ordered candidates by identifier alone, and a copy always carries a later ObjectId than the record it was copied from, so the shared record won every lookup. Both now prefer the record this store owns. Ordering keys on Stores.Contains(storeId) rather than LimitedToStores, because under IgnoreStoreLimitations the ACL admits everything and another store's record would otherwise win here. With the copy finally resolving, a list read for the storefront saw both records under one system name and rendered the page twice. The four render paths - footer, menu, sitemap, sitemap XML - collapse that with PreferStoreOverrides. It is deliberately not inside GetAllPages: both panels must keep listing the shared page and the copy as the two separate records they are. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GetAllPages returned Task.FromResult(query.ToList()) from a cache acquire lambda. Table is an IQueryable over the MongoDB driver, so ToList is a blocking round trip dressed up as async, and blocking there is worse than elsewhere because the cache holds a lock for the duration of the acquire. Same treatment #771 gave the paged queries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type: bugfix
Issue
Two defects in the same place: which CMS record a store gets when more than one matches.
1. Five CMS reads took the store as
string storeId = ""IBlogService.GetAllBlogPosts,GetAllBlogPostsByTag,GetAllBlogCategoriesINewsService.GetAllNewsIPageService.GetPageBySystemNameOmitting that argument is not a compile error, and it silently turns the tenant filter off — the same shape #767 removed from the category, brand and collection lists. Four callers had omitted it and nothing could report that.
Three of them were defects a user can hit:
ContactControllerresolved"ContactUs"without a store, on both the GET and the POST path. WithStoreClosedset, whether the contact page stays reachable was decided by whichever store's page matched the system name first.StorePageViewComponentresolved"StorePortalInfo"without a store, so a store manager could be shown another store's portal page.VendorPageViewComponentdid the same with"VendorPortalInfo".Reproduce (1): create two stores, give each a
ContactUspage limited to itself, close the store, and open/contactus— the page consulted is not reliably the current store's.2. The store panel's COPY button never copied anything
On
/Store/Page/Edit/{id}a store manager copies a page shared by every store in order to edit it for their own store. Pressing COPY silently returned to the list.Copyguarded onAccessToEntityByStore, which returns true only when the page is limited to stores, belongs to this store, and belongs to no other — the exact condition under which there is nothing left to copy. The button itself only renders when the page is shared (!LimitedToStores || Stores.Count > 1). The two predicates are mutually exclusive, so the entire action body was unreachable and every POST fell through toRedirectToAction("List").Reproduce: open the store panel, edit a page not limited to any store, press COPY — nothing is created, no error is shown.
Fixing the button exposed the reason nobody had noticed: a copy would have been inert anyway.
GetPageBySystemNameandGetMessageTemplateByNameordered candidates byIdalone, and a copy always carries a later ObjectId than the record it was copied from, so the shared record won every lookup. A store manager who copied a page and edited it would see no change on the storefront, and a store that overrode a message template would still send mail from the shared one.Reproduce (with the button fixed, or by inserting the second record by hand): give a store its own page under a system name that also exists as a shared page, and open the storefront in that store — the shared page renders.
Solution
Store scope on the five signatures
The defaults are gone, which made the compiler name every caller. No parameter was reordered:
storeIdis already first in four of the five methods and second inGetPageBySystemName, so there was no risk of positional arguments silently swapping meaning.The three defective callers now pass
_contextAccessor.StoreContext.CurrentStore.Id.The fourth,
Grand.Web.Admin/Controllers/SearchController, is correct as it stands and now says so with an explicitstoreId: "". The admin panel is global by design. Writing the empty store only where it is genuinely meant is the point of removing the default — filling it in everywhere would preserve exactly the bug this removes.The copy guard
Copynow rejects what it should have all along: a page this store cannot read (LimitedToStores && !Stores.Contains(storeId)). The existing "already store-specific, nothing to copy" check moves after it and keeps redirecting back toEdit, matching what the button offers.Override precedence
Both resolvers now prefer the record this store owns:
Keyed on
Stores.Contains(storeId)rather thanLimitedToStores, because underIgnoreStoreLimitationsthe ACL admits everything and another store's record would otherwise win here. LINQ ordering is stable, so the existingIdorder still decides ties within each group — an installation with no overrides resolves byte-identically to before.One page, not two, on the storefront
With the copy finally resolving, a list read saw both records under one system name: the shared page and the copy carry the same
SystemNameand the sameIncludeInFooter/IncludeInSitemap/IncludeInMenuflags, so the page rendered twice. A newPageExtensions.PreferStoreOverrides(storeId)drops the shared record wherever the store owns one under the same system name, applied at the four render paths:Grand.Web/Components/Footer.csGrand.Web/Features/Handlers/Catalog/GetMenuHandler.csGrand.Web/Features/Handlers/Common/GetSitemapHandler.csGrand.Business.Messages/Commands/Handlers/Common/GetSitemapXMLCommandHandler.csIt is deliberately not inside
GetAllPages. Both panels must keep listing the shared page and the copy as the two separate records they are, and keying the collapse off the existingignoreAclflag would reintroduce the same implicit coupling this PR removes from the five signatures.Repository reads instead of blocking ones
Separate commit, no behavior change.
GetAllPagesreturnedTask.FromResult(query.ToList())from a cache acquire lambda.Tableis anIQueryableover the MongoDB driver, soToListis a blocking round trip dressed up as async — and blocking inside an acquire is worse than elsewhere, because the cache holds a lock for its duration. Both resolvers andGetAllPagesnowawait _repository.ToListAsync(query), the shape #771 introduced.Scoped to the two files this PR already touches. Roughly a hundred more
Task.FromResult(query…)sites remain acrosssrc/Businessandsrc/Core; they are a mechanical sweep of their own, not something to bury in a bugfix.Audit of the same pattern elsewhere
OrderBy(x => x.Id)as a tiebreaker between a shared record and a store's own exists in exactly these two resolvers repository-wide. Checked and deliberately left alone:ClosedStoreAttribute— an allow-list of identifiers; both slugs stay valid URLs, which is correct.ProductController.CopyProduct— resolves by identifier or SeName, no shared system name to collide on.SearchTermService.GetSearchTermByKeyword,CustomerService.GetCustomerByEmail/GetCustomerByUsername,GetNewsLetterSubscriptionByEmailAndStoreId— hardstoreIdequality, no shared-record fallthrough to order against.CurrencyService— a list filter, not a single-record resolver.Breaking changes
None for existing installations.
GetPageBySystemNamefilters throughIAclService.Authorize, which admits any entity withLimitedToStores == false. A page not limited to a store keeps resolving in every store exactly as before; only a page explicitly limited to a different store stops being returned — the leak being closed. The new ordering changes the outcome only where a store already owns a record under a system name that also exists as a shared record, which is precisely the case that was broken.The five interface methods now require the store. Any out-of-tree caller that omitted it stops compiling, with the compiler pointing at the decision it needs to make. That is the intended effect.
PreferStoreOverridesis a new extension method; nothing existing changes signature.Testing
dotnet build ./GrandNode.sln— succeeds. (Grand.Webneeds IIS Express stopped first, or the DLL locks insrc/Web/Grand.Web/binsurface as MSB3021 — a lock, not a compile error.)dotnet test src/Tests/Grand.Business.Cms.Tests— 92 pass, including the twoGetPageBySystemNamestore-scope tests, the two precedence tests, and eight forPreferStoreOverrides(collapse, system-name casing, order preservation, empty store, another store's copy, null guard).dotnet test src/Tests/Grand.Business.Messages.Tests— 35 pass, including the twoGetMessageTemplateByNameprecedence tests.dotnet testonGrand.Web.Admin.Tests(59) andGrand.Web.Store.Tests(17) — pass.Grand.Web.Tests(9) passed on the earlier revision of this branch and needs IIS Express stopped to build again, for the reason in step 1./contactus— 200.ContactUslimited to store A, and browse the storefront as store B withStoreClosedenabled. Store B must not be judged by store A's page.StorePortalInfopage limited to another store no longer appears./sitemap.Note for the reviewer, out of scope here
src/Web/Grand.Web.Store/Components/StorePage.cssat in the Store project while declaringnamespace Grand.Web.Vendor.Componentsand inheritingBaseVendorViewComponentwith its[Area("Vendor")]. That is resolved onmainby #774, which this branch is rebased onto.PageService.GetAllPagesstill carries an orphaned{ … }block with noiforelseattached to it — a leftover from an earlier refactor. Purely cosmetic, left out to keep the diff scoped.🤖 Generated with Claude Code