Internal page cache serves default-language bodies to Accept-Language requests
Core's internal (anonymous) page cache keys responses by URL only (web/core/modules/page_cache/src/StackMiddleware/PageCache.php); it ignores request headers entirely (core #2430335). This module's page-cache kill switch previously fired only when a language header was present on the request. So an anonymous read with no selector would warm a URL's page cache entry with its default-language body, and a later anonymous request to the same URL WITH "Accept-Language" would silently be served that stale, wrong-language body straight from the page cache, bypassing this module's negotiation entirely (and likewise for a langCode/Accept-Language combination that should 406).
Trigger the kill switch unconditionally, before any negotiation or throw, on every language-capable read path: individual content and config (including the config 406, which previously fired its trigger after the throw), collection, related (including its to-one 404/406, moved ahead of the throw for the same reason), and the jsonapi_menu_items companion route.
The collection path needed a second pass: its trigger initially sat after parent::getCollection(), but getCollection() calls getResourceLanguage() at its top, which can itself throw a header-triggered 400 (combining "langCode" and "Accept-Language", or a "Content-Language" header on a cacheable request) before the trigger ever ran. Core's page cache stores 4xx responses, so that 400 could be cached and replayed to a later selector-free request for the same URL. Moved the trigger to the top of getCollection(), before getResourceLanguage() and every other language-dependent outcome, mirroring getIndividual(). Extended InternalPageCachePoisoningTest with this exact sequence (a combined-selector 400 followed by a selector-free request for the identical URL) and confirmed it fails with the trigger in its old position and passes with it moved.
While verifying, found that core's shared "page_cache_kill_switch" service is tagged into BOTH the internal and the dynamic page-cache response-policy chains (core.services.yml), so triggering it disables both caches, not just the internal one. Only the internal page cache is actually unsafe here: it ignores cache contexts entirely, while the dynamic page cache correctly varies by the declared "url.query_args:langCode" and "headers:Accept-Language" contexts. Added a module-owned instance of the same KillSwitch class (jsonapi_multilingual.page_cache_kill_switch), tagged into the internal chain only, and pointed every consumer at it (the entity resource controller via both its child service definition and the JsonapiMultilingualServiceProvider alter() that wires the live jsonapi.entity_resource service, and the menu items language subscriber). The dynamic page cache keeps serving these reads.
Added InternalPageCachePoisoningTest, a standalone regression test (enables page_cache without disturbing JsonApiTranslationFunctionalTest's suite) that reproduces the exact poisoning sequence for individual, collection, and menu-items reads; confirmed it fails before this fix and passes after. Reconciled MenuItemsTranslationTest's X-Drupal-Cache assertions, which encoded the superseded (header-conditional) contract.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Closes #3613686