From 19f80f658ed319cb41ffd78082e5582673aa1ef0 Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Wed, 2 Oct 2024 13:39:07 -0700
Subject: [PATCH] Issue #2951814 by wim leers, mxr576, vakulrai,
 claudiu.cristea, neclimdul, alexpott, sanjayk, joachim, chi, smustgrave:
 Improve X-Drupal-Cache and X-Drupal-Dynamic-Cache headers, even for responses
 that are not cacheable

---
 .../tests/src/Functional/BasicAuthTest.php    |  4 +-
 .../src/Functional/DbLogResourceTest.php      |  3 +-
 .../DynamicPageCacheSubscriber.php            | 16 +++-
 .../DynamicPageCacheIntegrationTest.php       | 21 ++---
 .../tests/src/Functional/FileUploadTest.php   |  2 +-
 .../jsonapi/tests/src/Functional/NodeTest.php |  4 +-
 .../Functional/ResourceResponseTestTrait.php  |  2 +-
 .../tests/src/Functional/ResourceTestBase.php | 89 +++++++++----------
 ...uageBrowserDetectionAcceptLanguageTest.php | 12 +--
 .../src/Functional/LayoutSectionTest.php      | 11 +--
 .../Functional/NodeBlockFunctionalTest.php    |  2 +-
 .../src/StackMiddleware/PageCache.php         | 16 +++-
 .../tests/src/Functional/PageCacheTest.php    | 12 +--
 .../EntityResource/EntityResourceTestBase.php | 17 ++--
 .../tests/src/Functional/ResourceTestBase.php |  8 +-
 .../src/Functional/Session/SessionTest.php    |  2 +-
 .../Functional/System/ErrorHandlerTest.php    |  2 +-
 .../src/Functional/UserPasswordResetTest.php  |  4 +-
 .../tests/src/Traits/StandardTestTrait.php    |  2 +-
 19 files changed, 127 insertions(+), 102 deletions(-)

diff --git a/core/modules/basic_auth/tests/src/Functional/BasicAuthTest.php b/core/modules/basic_auth/tests/src/Functional/BasicAuthTest.php
index c91b28eabf96..2d3ede221525 100644
--- a/core/modules/basic_auth/tests/src/Functional/BasicAuthTest.php
+++ b/core/modules/basic_auth/tests/src/Functional/BasicAuthTest.php
@@ -53,7 +53,7 @@ public function testBasicAuth(): void {
     $this->assertSession()->pageTextContains($account->getAccountName());
     $this->assertSession()->statusCodeEquals(200);
     $this->mink->resetSessions();
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
     // Check that Cache-Control is not set to public.
     $this->assertSession()->responseHeaderNotContains('Cache-Control', 'public');
 
@@ -87,7 +87,7 @@ public function testBasicAuth(): void {
     $this->drupalGet($url);
     $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'MISS');
     $this->basicAuthGet($url, $account->getAccountName(), $account->pass_raw);
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
     // Check that Cache-Control is not set to public.
     $this->assertSession()->responseHeaderNotContains('Cache-Control', 'public');
   }
diff --git a/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php b/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php
index efd97a84aa90..b93c5e1b22a5 100644
--- a/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php
+++ b/core/modules/dblog/tests/src/Functional/DbLogResourceTest.php
@@ -85,7 +85,8 @@ public function testWatchdog(): void {
       "The 'restful get dblog' permission is required.",
       $response,
       ['4xx-response', 'http_response'],
-      ['user.permissions']
+      ['user.permissions'],
+      'UNCACHEABLE (request policy)'
     );
 
     // Create a user account that has the required permissions to read
diff --git a/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php b/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php
index 005d6a194584..3cf49b65c516 100644
--- a/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php
+++ b/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php
@@ -156,11 +156,18 @@ public function onRequest(RequestEvent $event) {
   public function onResponse(ResponseEvent $event) {
     $response = $event->getResponse();
 
+    // Don't indicate non-cacheability on responses to uncacheable requests.
+    // @see https://tools.ietf.org/html/rfc7231#section-4.2.3
+    if (!$event->getRequest()->isMethodCacheable()) {
+      return;
+    }
+
     // Dynamic Page Cache only works with cacheable responses. It does not work
     // with plain Response objects. (Dynamic Page Cache needs to be able to
     // access and modify the cacheability metadata associated with the
     // response.)
     if (!$response instanceof CacheableResponseInterface) {
+      $response->headers->set(self::HEADER, 'UNCACHEABLE (no cacheability)');
       return;
     }
 
@@ -172,7 +179,7 @@ public function onResponse(ResponseEvent $event) {
     // There's no work left to be done if this is an uncacheable response.
     if (!$this->shouldCacheResponse($response)) {
       // The response is uncacheable, mark it as such.
-      $response->headers->set(self::HEADER, 'UNCACHEABLE');
+      $response->headers->set(self::HEADER, 'UNCACHEABLE (poor cacheability)');
       return;
     }
 
@@ -195,7 +202,12 @@ public function onResponse(ResponseEvent $event) {
     // Don't cache the response if the Dynamic Page Cache request & response
     // policies are not met.
     // @see onRequest()
-    if ($this->requestPolicyResults[$request] === RequestPolicyInterface::DENY || $this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
+    if ($this->requestPolicyResults[$request] === RequestPolicyInterface::DENY) {
+      $response->headers->set(self::HEADER, 'UNCACHEABLE (request policy)');
+      return;
+    }
+    if ($this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
+      $response->headers->set(self::HEADER, 'UNCACHEABLE (response policy)');
       return;
     }
 
diff --git a/core/modules/dynamic_page_cache/tests/src/Functional/DynamicPageCacheIntegrationTest.php b/core/modules/dynamic_page_cache/tests/src/Functional/DynamicPageCacheIntegrationTest.php
index afa08e131749..ac26aeab3a33 100644
--- a/core/modules/dynamic_page_cache/tests/src/Functional/DynamicPageCacheIntegrationTest.php
+++ b/core/modules/dynamic_page_cache/tests/src/Functional/DynamicPageCacheIntegrationTest.php
@@ -53,7 +53,7 @@ public function testDynamicPageCache(): void {
     // Cache.
     $url = Url::fromUri('route:dynamic_page_cache_test.response');
     $this->drupalGet($url);
-    $this->assertSession()->responseHeaderDoesNotExist(DynamicPageCacheSubscriber::HEADER);
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (no cacheability)');
 
     // Controllers returning CacheableResponseInterface (cacheable response)
     // objects are handled by Dynamic Page Cache.
@@ -97,27 +97,28 @@ public function testDynamicPageCache(): void {
     // response, are ignored by Dynamic Page Cache (but only because those
     // wrapper formats' responses do not implement CacheableResponseInterface).
     $this->drupalGet('dynamic-page-cache-test/html', ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']]);
-    $this->assertSession()->responseHeaderDoesNotExist(DynamicPageCacheSubscriber::HEADER);
     $this->drupalGet('dynamic-page-cache-test/html', ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_dialog']]);
-    $this->assertSession()->responseHeaderDoesNotExist(DynamicPageCacheSubscriber::HEADER);
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (no cacheability)');
     $this->drupalGet('dynamic-page-cache-test/html', ['query' => [MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_modal']]);
-    $this->assertSession()->responseHeaderDoesNotExist(DynamicPageCacheSubscriber::HEADER);
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (no cacheability)');
 
     // Admin routes are ignored by Dynamic Page Cache.
     $this->drupalGet('dynamic-page-cache-test/html/admin');
-    $this->assertSession()->responseHeaderDoesNotExist(DynamicPageCacheSubscriber::HEADER);
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (response policy)');
     $this->drupalGet('dynamic-page-cache-test/response/admin');
-    $this->assertSession()->responseHeaderDoesNotExist(DynamicPageCacheSubscriber::HEADER);
+    // Even this is an admin route, the lack of cacheability prevails in the
+    // header explanation.
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (no cacheability)');
     $this->drupalGet('dynamic-page-cache-test/cacheable-response/admin');
-    $this->assertSession()->responseHeaderDoesNotExist(DynamicPageCacheSubscriber::HEADER);
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (response policy)');
 
     // Max-age = 0 responses are ignored by Dynamic Page Cache.
     $this->drupalGet('dynamic-page-cache-test/html/uncacheable/max-age');
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (poor cacheability)');
 
     // 'user' cache context responses are ignored by Dynamic Page Cache.
     $this->drupalGet('dynamic-page-cache-test/html/uncacheable/contexts');
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (poor cacheability)');
 
     // 'current-temperature' cache tag responses are ignored by Dynamic Page
     // Cache.
@@ -127,7 +128,7 @@ public function testDynamicPageCache(): void {
     // Route access checkers can also bubble up cacheability data.
     $this->drupalGet('/dynamic-page-cache-test/html/uncacheable/route-access');
     $this->assertSession()->responseHeaderExists(DynamicPageCacheSubscriber::HEADER);
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (poor cacheability)');
   }
 
 }
diff --git a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
index 9877b96cf383..a667ea6653bb 100644
--- a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php
@@ -287,7 +287,7 @@ public function testPostFileUploadAndUseInSingleRequest(): void {
     // This request fails despite the upload succeeding, because we're not
     // allowed to view the entity we're uploading to.
     $response = $this->fileRequest($uri, $this->testFileData);
-    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $uri, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site', 'user.permissions'], FALSE, 'UNCACHEABLE');
+    $this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('GET'), $uri, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site', 'user.permissions']);
 
     $this->setUpAuthorization('GET');
 
diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
index 0150d1376ab5..f0d490976523 100644
--- a/core/modules/jsonapi/tests/src/Functional/NodeTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php
@@ -345,7 +345,7 @@ public function testGetIndividual(): void {
       '/data',
       ['4xx-response', 'http_response', 'node:1'],
       ['url.query_args', 'url.site', 'user.permissions'],
-      FALSE,
+      'UNCACHEABLE (request policy)',
       'MISS'
     );
 
@@ -356,7 +356,7 @@ public function testGetIndividual(): void {
     // context to be optimized away.
     $expected_cache_contexts = Cache::mergeContexts($this->getExpectedCacheContexts(), ['user']);
     $expected_cache_contexts = array_diff($expected_cache_contexts, ['user.permissions']);
-    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $expected_cache_contexts, FALSE, 'UNCACHEABLE');
+    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $expected_cache_contexts, 'UNCACHEABLE (request policy)', 'UNCACHEABLE (poor cacheability)');
   }
 
   /**
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
index 510035a070ab..07b63ded251d 100644
--- a/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceResponseTestTrait.php
@@ -244,7 +244,7 @@ protected static function toResourceResponse(ResponseInterface $response) {
       $cacheability->addCacheContexts(explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
     }
     if ($dynamic_cache = $response->getHeader('X-Drupal-Dynamic-Cache')) {
-      $cacheability->setCacheMaxAge(($dynamic_cache[0] === 'UNCACHEABLE' && $response->getStatusCode() < 400) ? 0 : Cache::PERMANENT);
+      $cacheability->setCacheMaxAge((str_contains($dynamic_cache[0], 'UNCACHEABLE') && $response->getStatusCode() < 400) ? 0 : Cache::PERMANENT);
     }
     $related_document = Json::decode($response->getBody());
     $resource_response = new CacheableResourceResponse($related_document, $response->getStatusCode());
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
index c37f77ee6b4c..2fe7a4510db7 100644
--- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
@@ -749,17 +749,16 @@ protected function assertResourceResponse($expected_status_code, $expected_docum
       $this->assertTrue($response->hasHeader('X-Drupal-Cache'));
       $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
     }
-    else {
-      $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    elseif ($response->hasHeader('X-Drupal-Cache')) {
+      $this->assertMatchesRegularExpression('#^UNCACHEABLE \((no cacheability|(request|response) policy)\)$#', $response->getHeader('X-Drupal-Cache')[0]);
     }
-
     // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
     if ($expected_dynamic_page_cache_header_value !== FALSE) {
       $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
       $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
     }
-    else {
-      $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
+    elseif ($response->hasHeader('X-Drupal-Dynamic-Cache')) {
+      $this->assertMatchesRegularExpression('#^UNCACHEABLE \(((no|poor) cacheability|(request|response) policy)\)$#', $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
     }
   }
 
@@ -935,8 +934,8 @@ public function testGetIndividual(): void {
       $reason = $this->getExpectedUnauthorizedAccessMessage('GET');
       $message = trim("The current user is not allowed to GET the selected resource. $reason");
       // MISS or UNCACHEABLE depends on data. It must not be HIT.
-      $dynamic_cache_header_value = !empty(array_intersect(['user', 'session'], $expected_403_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
-      $this->assertResourceErrorResponse(403, $message, $url, $response, '/data', $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, $dynamic_cache_header_value);
+      $dynamic_cache_header_value = !empty(array_intersect(['user', 'session'], $expected_403_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
+      $this->assertResourceErrorResponse(403, $message, $url, $response, '/data', $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache_header_value);
       $this->assertArrayNotHasKey('Link', $response->getHeaders());
     }
     else {
@@ -945,8 +944,8 @@ public function testGetIndividual(): void {
       $expected_document['data']['attributes'] = array_intersect_key($expected_document['data']['attributes'], [$label_field_name => TRUE]);
       unset($expected_document['data']['relationships']);
       // MISS or UNCACHEABLE depends on data. It must not be HIT.
-      $dynamic_cache_label_only = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts([$label_field_name]))) ? 'UNCACHEABLE' : 'MISS';
-      $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts([$label_field_name]), FALSE, $dynamic_cache_label_only);
+      $dynamic_cache_label_only = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts([$label_field_name]))) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
+      $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts([$label_field_name]), 'UNCACHEABLE (request policy)', $dynamic_cache_label_only);
     }
 
     $this->setUpAuthorization('GET');
@@ -972,20 +971,20 @@ public function testGetIndividual(): void {
         ],
       ],
     ];
-    $this->assertResourceResponse(400, $expected_document, $response, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'MISS');
+    $this->assertResourceResponse(400, $expected_document, $response, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], 'UNCACHEABLE (request policy)', 'MISS');
 
     // 200 for well-formed HEAD request.
     $response = $this->request('HEAD', $url, $request_options);
     // MISS or UNCACHEABLE depends on data. It must not be HIT.
-    $dynamic_cache = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
-    $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache);
+    $dynamic_cache = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
+    $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
     $head_headers = $response->getHeaders();
 
     // 200 for well-formed GET request. Page Cache hit because of HEAD request.
     // Same for Dynamic Page Cache hit.
     $response = $this->request('GET', $url, $request_options);
 
-    $this->assertResourceResponse(200, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE');
+    $this->assertResourceResponse(200, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE (poor cacheability)');
     // Assert that Dynamic Page Cache did not store a ResourceResponse object,
     // which needs serialization after every cache hit. Instead, it should
     // contain a flattened response. Otherwise performance suffers.
@@ -1012,7 +1011,7 @@ public function testGetIndividual(): void {
         $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response);
       }
     }
-    $this->assertSame($dynamic_cache !== 'UNCACHEABLE' || isset($dynamic_cache_label_only) && $dynamic_cache_label_only !== 'UNCACHEABLE', $found_cached_200_response);
+    $this->assertSame($dynamic_cache !== 'UNCACHEABLE (poor cacheability)' || isset($dynamic_cache_label_only) && $dynamic_cache_label_only !== 'UNCACHEABLE (poor cacheability)', $found_cached_200_response);
     $this->assertTrue($other_cached_responses_are_4xx);
 
     // Not only assert the normalization, also assert deserialization of the
@@ -1061,12 +1060,12 @@ public function testGetIndividual(): void {
     $message_url = clone $url;
     $path = str_replace($random_uuid, '{entity}', $message_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString());
     $message = 'The "entity" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")';
-    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'UNCACHEABLE');
+    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], 'UNCACHEABLE (request policy)', 'UNCACHEABLE (poor cacheability)');
 
     // DX: when Accept request header is missing, still 404, same response.
     unset($request_options[RequestOptions::HEADERS]['Accept']);
     $response = $this->request('GET', $url, $request_options);
-    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'UNCACHEABLE');
+    $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], 'UNCACHEABLE (request policy)', 'UNCACHEABLE (poor cacheability)');
   }
 
   /**
@@ -1087,8 +1086,8 @@ public function testCollection(): void {
     $expected_cacheability = $expected_response->getCacheableMetadata();
     $response = $this->request('HEAD', $collection_url, $request_options);
     // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
-    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
-    $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
+    $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
 
     // Different databases have different sort orders, so a sort is required so
     // test expectations do not need to vary per database.
@@ -1101,10 +1100,10 @@ public function testCollection(): void {
     $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
     $expected_cacheability = $expected_response->getCacheableMetadata();
     // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
-    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
     $expected_document = $expected_response->getResponseData();
     $response = $this->request('GET', $collection_url, $request_options);
-    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
 
     $this->setUpAuthorization('GET');
 
@@ -1112,9 +1111,9 @@ public function testCollection(): void {
     $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
     $expected_cacheability = $expected_response->getCacheableMetadata();
     // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
-    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
     $response = $this->request('HEAD', $collection_url, $request_options);
-    $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
 
     // 200 for well-formed GET request.
     $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options);
@@ -1122,8 +1121,8 @@ public function testCollection(): void {
     $expected_document = $expected_response->getResponseData();
     $response = $this->request('GET', $collection_url, $request_options);
     // Dynamic Page Cache HIT unless the HEAD request was UNCACHEABLE.
-    $dynamic_cache = $dynamic_cache === 'UNCACHEABLE' ? 'UNCACHEABLE' : 'HIT';
-    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    $dynamic_cache = $dynamic_cache === 'UNCACHEABLE (poor cacheability)' ? 'UNCACHEABLE (poor cacheability)' : 'HIT';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
 
     if ($this->entity instanceof FieldableEntityInterface) {
       // 403 for filtering on an unauthorized field on the base resource type.
@@ -1145,14 +1144,14 @@ public function testCollection(): void {
         'url.site',
         'user.permissions',
       ];
-      $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+      $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, 'UNCACHEABLE (request policy)', 'MISS');
 
       $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']);
 
       // 403 for filtering on an unauthorized field on a related resource type.
       $response = $this->request('GET', $unauthorized_filter_url, $request_options);
       $expected_error_message = "The current user is not authorized to filter by the `status` field, given in the path `field_jsonapi_test_entity_ref.entity:user.status`.";
-      $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS');
+      $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, 'UNCACHEABLE (request policy)', 'MISS');
     }
 
     // Remove an entity from the collection, then filter it out.
@@ -1176,8 +1175,8 @@ public function testCollection(): void {
     $expected_document = $expected_response->getResponseData();
     $response = $this->request('GET', $filtered_collection_url, $request_options);
     // MISS or UNCACHEABLE depends on the collection data. It must not be HIT.
-    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
-    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
 
     // Filtered collection with includes.
     $relationship_field_names = array_reduce($filtered_entity_collection, function ($relationship_field_names, $entity) {
@@ -1192,8 +1191,8 @@ public function testCollection(): void {
     $expected_document = $expected_response->getResponseData();
     $response = $this->request('GET', $filtered_collection_include_url, $request_options);
     // MISS or UNCACHEABLE depends on the included data. It must not be HIT.
-    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
-    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
 
     // If the response should vary by a user's authorizations, grant permissions
     // for the included resources and execute another request.
@@ -1212,8 +1211,8 @@ public function testCollection(): void {
       $response = $this->request('GET', $filtered_collection_include_url, $request_options);
       $requires_include_only_permissions = !empty($flattened_permissions);
       $uncacheable = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts()));
-      $dynamic_cache = !$uncacheable ? $requires_include_only_permissions ? 'MISS' : 'HIT' : 'UNCACHEABLE';
-      $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+      $dynamic_cache = !$uncacheable ? $requires_include_only_permissions ? 'MISS' : 'HIT' : 'UNCACHEABLE (poor cacheability)';
+      $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
     }
 
     // Sorted collection with includes.
@@ -1230,8 +1229,8 @@ public function testCollection(): void {
     $expected_document = $expected_response->getResponseData();
     $response = $this->request('GET', $sorted_collection_include_url, $request_options);
     // MISS or UNCACHEABLE depends on the included data. It must not be HIT.
-    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS';
-    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
+    $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
+    $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache);
   }
 
   /**
@@ -1387,8 +1386,8 @@ protected function doTestRelated(array $request_options) {
         $expected_cacheability->getCacheContexts(),
         FALSE,
         $actual_response->getStatusCode() === 200
-          ? ($expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS')
-          : (!empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : FALSE)
+          ? ($expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE (poor cacheability)' : 'MISS')
+          : (!empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : FALSE)
       );
     }
   }
@@ -1422,10 +1421,10 @@ protected function doTestRelationshipGet(array $request_options) {
         $actual_response,
         $expected_cacheability->getCacheTags(),
         $expected_cacheability->getCacheContexts(),
-        FALSE,
+        'UNCACHEABLE (request policy)',
         empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts()))
           ? $expected_resource_response->isSuccessful() ? 'MISS' : FALSE
-          : 'UNCACHEABLE'
+          : 'UNCACHEABLE (poor cacheability)'
       );
     }
   }
@@ -2725,21 +2724,21 @@ protected function doTestSparseFieldSets(Url $url, array $request_options) {
       $response = $this->request('GET', $url, $request_options);
       // Dynamic Page Cache MISS because cache should vary based on the 'field'
       // query param. (Or uncacheable if expensive cache context.)
-      $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+      $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
       $this->assertResourceResponse(
         200,
         $expected_document,
         $response,
         $expected_cacheability->getCacheTags(),
         $expected_cacheability->getCacheContexts(),
-        FALSE,
+        'UNCACHEABLE (request policy)',
         $dynamic_cache
       );
     }
     // Test Dynamic Page Cache HIT for a query with the same field set (unless
     // expensive cache context is present).
     $response = $this->request('GET', $url, $request_options);
-    $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE');
+    $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), 'UNCACHEABLE (request policy)', $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE (poor cacheability)');
   }
 
   /**
@@ -2784,7 +2783,7 @@ protected function doTestIncluded(Url $url, array $request_options) {
       // 'include' query param.
       $expected_cacheability = $expected_response->getCacheableMetadata();
       // MISS or UNCACHEABLE depends on data. It must not be HIT.
-      $dynamic_cache = ($expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts()))) ? 'UNCACHEABLE' : 'MISS';
+      $dynamic_cache = ($expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts()))) ? FALSE : 'MISS';
       $this->assertResourceResponse(
         200,
         $expected_document,
@@ -2896,7 +2895,7 @@ public function testRevisions(): void {
       $detail .= ' ' . $reason;
     }
     // MISS or UNCACHEABLE depends on data. It must not be HIT.
-    $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+    $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
     $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
 
     // Ensure that targeting a revision does not bypass access.
@@ -2907,7 +2906,7 @@ public function testRevisions(): void {
       $detail .= ' ' . $reason;
     }
     // MISS or UNCACHEABLE depends on data. It must not be HIT.
-    $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+    $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
     $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
 
     $this->setUpRevisionAuthorization('GET');
@@ -3239,7 +3238,7 @@ public function testRevisions(): void {
       $expected_cacheability = $expected_response->getCacheableMetadata();
       $expected_document['links']['self']['href'] = $related_url->toString();
       // MISS or UNCACHEABLE depends on data. It must not be HIT.
-      $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS';
+      $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
       $expected_cache_tags = !in_array($relationship_type, $default_revision_types, TRUE) ? Cache::mergeTags($expected_cacheability->getCacheTags(), $this->getExtraRevisionCacheTags()) : $expected_cacheability->getCacheTags();
       $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache);
     }
diff --git a/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php b/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php
index 157db6ab33b1..d02c9cec4cc2 100644
--- a/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php
+++ b/core/modules/language/tests/src/Functional/LanguageBrowserDetectionAcceptLanguageTest.php
@@ -87,32 +87,32 @@ public function testAcceptLanguageEmptyDefault(): void {
 
     $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'en']);
     $this->assertSession()->responseHeaderEquals('Content-Language', 'en');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (response policy)');
 
     // Check with UK browser.
     $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'en-UK,en']);
     $this->assertSession()->responseHeaderEquals('Content-Language', 'en');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (response policy)');
 
     // Check with french browser.
     $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'fr-FR,fr']);
     $this->assertSession()->responseHeaderEquals('Content-Language', 'fr');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (response policy)');
 
     // Check with browser without language settings - should return fallback language.
     $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => '']);
     $this->assertSession()->responseHeaderEquals('Content-Language', 'en');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (response policy)');
 
     // Check with french browser again.
     $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'fr-FR,fr']);
     $this->assertSession()->responseHeaderEquals('Content-Language', 'fr');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (response policy)');
 
     // Check with UK browser.
     $this->drupalGet('/system-test/echo/language test', [], ['Accept-Language' => 'en-UK,en']);
     $this->assertSession()->responseHeaderEquals('Content-Language', 'en');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (response policy)');
 
     // Check if prefixed URLs are still cached.
     $this->drupalGet('/en/system-test/echo/language test', [], ['Accept-Language' => 'en']);
diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php
index 3988454611cc..71b44fe40d55 100644
--- a/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php
+++ b/core/modules/layout_builder/tests/src/Functional/LayoutSectionTest.php
@@ -85,7 +85,7 @@ public static function providerTestLayoutSectionFormatter() {
       ],
       'user',
       'user:2',
-      'UNCACHEABLE',
+      'UNCACHEABLE (poor cacheability)',
     ];
     $data['block_with_entity_context'] = [
       [
@@ -179,7 +179,7 @@ public function testLayoutSectionFormatter($layout_data, $expected_selector, $ex
     $this->assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, $expected_dynamic_cache);
 
     $this->drupalGet($canonical_url->toString() . '/layout');
-    $this->assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, 'UNCACHEABLE');
+    $this->assertLayoutSection($expected_selector, $expected_content, $expected_cache_contexts, $expected_cache_tags, 'UNCACHEABLE (poor cacheability)');
   }
 
   /**
@@ -200,14 +200,14 @@ public function testLayoutSectionFormatterAccess(): void {
     $this->container->get('state')->set('test_block_access', FALSE);
 
     $this->drupalGet($node->toUrl('canonical'));
-    $this->assertLayoutSection('.layout--onecol', NULL, '', '', 'UNCACHEABLE');
+    $this->assertLayoutSection('.layout--onecol', NULL, '', '', 'UNCACHEABLE (poor cacheability)');
     // Ensure the block was not rendered.
     $this->assertSession()->pageTextNotContains('Hello test world');
 
     // Grant access to the block, and ensure it was rendered.
     $this->container->get('state')->set('test_block_access', TRUE);
     $this->drupalGet($node->toUrl('canonical'));
-    $this->assertLayoutSection('.layout--onecol', 'Hello test world', '', '', 'UNCACHEABLE');
+    $this->assertLayoutSection('.layout--onecol', 'Hello test world', '', '', 'UNCACHEABLE (poor cacheability)');
   }
 
   /**
@@ -294,7 +294,8 @@ public function testLayoutDeletingBundle(): void {
    * @param string $expected_cache_tags
    *   A string of cache tags to be found in the header.
    * @param string $expected_dynamic_cache
-   *   The expected dynamic cache header. Either 'HIT', 'MISS' or 'UNCACHEABLE'.
+   *   The expected dynamic cache header. Either 'HIT', 'MISS' or
+   *   'UNCACHEABLE (poor cacheability)'.
    *
    * @internal
    */
diff --git a/core/modules/node/tests/src/Functional/NodeBlockFunctionalTest.php b/core/modules/node/tests/src/Functional/NodeBlockFunctionalTest.php
index 0b0287f1ac36..cda97950c642 100644
--- a/core/modules/node/tests/src/Functional/NodeBlockFunctionalTest.php
+++ b/core/modules/node/tests/src/Functional/NodeBlockFunctionalTest.php
@@ -185,7 +185,7 @@ public function testRecentNodeBlock(): void {
     $this->assertCacheContexts(['languages:language_content', 'languages:language_interface', 'session', 'theme', 'url.path', 'url.query_args', 'user', 'route']);
 
     // The node/add/article page is an admin path and currently uncacheable.
-    $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'UNCACHEABLE');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Dynamic-Cache', 'UNCACHEABLE (poor cacheability)');
 
     $this->drupalGet('node/' . $node1->id());
     // Check that block is displayed on the node page when node is of type
diff --git a/core/modules/page_cache/src/StackMiddleware/PageCache.php b/core/modules/page_cache/src/StackMiddleware/PageCache.php
index efe4e3b99c29..2d0d3d3b0512 100644
--- a/core/modules/page_cache/src/StackMiddleware/PageCache.php
+++ b/core/modules/page_cache/src/StackMiddleware/PageCache.php
@@ -19,6 +19,11 @@
  */
 class PageCache implements HttpKernelInterface {
 
+  /**
+   * Name of Page Cache's response header.
+   */
+  const HEADER = 'X-Drupal-Cache';
+
   /**
    * The wrapped HTTP kernel.
    *
@@ -83,6 +88,11 @@ public function handle(Request $request, $type = self::MAIN_REQUEST, $catch = TR
     }
     else {
       $response = $this->pass($request, $type, $catch);
+      // Don't indicate non-cacheability on responses to uncacheable requests.
+      // @see https://tools.ietf.org/html/rfc7231#section-4.2.3
+      if ($request->isMethodCacheable()) {
+        $response->headers->set(static::HEADER, 'UNCACHEABLE (request policy)');
+      }
     }
 
     return $response;
@@ -122,7 +132,7 @@ protected function pass(Request $request, $type = self::MAIN_REQUEST, $catch = T
    */
   protected function lookup(Request $request, $type = self::MAIN_REQUEST, $catch = TRUE) {
     if ($response = $this->get($request)) {
-      $response->headers->set('X-Drupal-Cache', 'HIT');
+      $response->headers->set(static::HEADER, 'HIT');
     }
     else {
       $response = $this->fetch($request, $type, $catch);
@@ -193,7 +203,7 @@ protected function fetch(Request $request, $type = self::MAIN_REQUEST, $catch =
     // Only set the 'X-Drupal-Cache' header if caching is allowed for this
     // response.
     if ($this->storeResponse($request, $response)) {
-      $response->headers->set('X-Drupal-Cache', 'MISS');
+      $response->headers->set(static::HEADER, 'MISS');
     }
 
     return $response;
@@ -233,6 +243,7 @@ protected function storeResponse(Request $request, Response $response) {
     //   so by replacing/extending this middleware service or adding another
     //   one.
     if (!$response instanceof CacheableResponseInterface) {
+      $response->headers->set(static::HEADER, 'UNCACHEABLE (no cacheability)');
       return FALSE;
     }
 
@@ -246,6 +257,7 @@ protected function storeResponse(Request $request, Response $response) {
 
     // Allow policy rules to further restrict which responses to cache.
     if ($this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) {
+      $response->headers->set(static::HEADER, 'UNCACHEABLE (response policy)');
       return FALSE;
     }
 
diff --git a/core/modules/page_cache/tests/src/Functional/PageCacheTest.php b/core/modules/page_cache/tests/src/Functional/PageCacheTest.php
index 6a01a8bfce96..1cbb2cf0c5a3 100644
--- a/core/modules/page_cache/tests/src/Functional/PageCacheTest.php
+++ b/core/modules/page_cache/tests/src/Functional/PageCacheTest.php
@@ -215,7 +215,7 @@ public function testConditionalRequests(): void {
     $this->drupalGet('', [], ['If-Modified-Since' => $last_modified, 'If-None-Match' => $etag]);
     $this->assertSession()->statusCodeEquals(200);
     // Verify that absence of Page was not cached.
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
     $this->drupalLogout();
   }
 
@@ -279,7 +279,7 @@ protected function testPageCacheHeaders(): void {
     $user = $this->drupalCreateUser();
     $this->drupalLogin($user);
     $this->drupalGet('system-test/set-header', ['query' => ['name' => 'Foo', 'value' => 'bar']]);
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
     $this->assertSession()->responseHeaderNotContains('Vary', 'cookie');
     $this->assertSession()->responseHeaderEquals('Cache-Control', 'must-revalidate, no-cache, private');
     $this->assertSession()->responseHeaderEquals('Expires', 'Sun, 19 Nov 1978 05:00:00 GMT');
@@ -472,22 +472,22 @@ public function testCacheableResponseResponses(): void {
 
     // GET a URL, which would be marked as a cache miss if it were cacheable.
     $this->drupalGet('/system-test/respond-response');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (no cacheability)');
     $this->assertSession()->responseHeaderEquals('Cache-Control', 'must-revalidate, no-cache, private');
 
     // GET it again, verify it's still not cached.
     $this->drupalGet('/system-test/respond-response');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (no cacheability)');
     $this->assertSession()->responseHeaderEquals('Cache-Control', 'must-revalidate, no-cache, private');
 
     // GET a URL, which would be marked as a cache miss if it were cacheable.
     $this->drupalGet('/system-test/respond-public-response');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (no cacheability)');
     $this->assertSession()->responseHeaderEquals('Cache-Control', 'max-age=60, public');
 
     // GET it again, verify it's still not cached.
     $this->drupalGet('/system-test/respond-public-response');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (no cacheability)');
     $this->assertSession()->responseHeaderEquals('Cache-Control', 'max-age=60, public');
 
     // GET a URL, which should be marked as a cache miss.
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
index 2195c4ea67a9..9e9f7bb89910 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -485,7 +485,7 @@ public function testGet(): void {
     else {
       $this->assertResourceErrorResponse(403, FALSE, $response, $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), static::$auth ? FALSE : 'MISS', FALSE);
     }
-    $this->assertSame(static::$auth ? [] : ['MISS'], $response->getHeader('X-Drupal-Cache'));
+    $this->assertSame(static::$auth ? ['UNCACHEABLE (request policy)'] : ['MISS'], $response->getHeader('X-Drupal-Cache'));
     // DX: 403 because unauthorized.
     $url->setOption('query', ['_format' => static::$format]);
     $response = $this->request('GET', $url, $request_options);
@@ -501,15 +501,15 @@ public function testGet(): void {
     $response = $this->request('GET', $url, $request_options);
     if ($has_canonical_url) {
       $this->assertSame(403, $response->getStatusCode());
-      $dynamic_cache = str_starts_with($response->getHeader('X-Drupal-Cache-Max-Age')[0], '0') || !empty(array_intersect(['user', 'session'], explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]))) ? 'UNCACHEABLE' : 'MISS';
+      $dynamic_cache = str_starts_with($response->getHeader('X-Drupal-Cache-Max-Age')[0], '0') || !empty(array_intersect(['user', 'session'], explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]))) ? 'UNCACHEABLE (poor cacheability)' : 'MISS';
       $this->assertSame([$dynamic_cache], $response->getHeader('X-Drupal-Dynamic-Cache'));
     }
     else {
       $this->assertSame(406, $response->getStatusCode());
-      $this->assertSame(['UNCACHEABLE'], $response->getHeader('X-Drupal-Dynamic-Cache'));
+      $this->assertSame(['UNCACHEABLE (poor cacheability)'], $response->getHeader('X-Drupal-Dynamic-Cache'));
     }
     $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type'));
-    $this->assertSame(static::$auth ? [] : ['MISS'], $response->getHeader('X-Drupal-Cache'));
+    $this->assertSame(static::$auth ? ['UNCACHEABLE (request policy)'] : ['MISS'], $response->getHeader('X-Drupal-Cache'));
     // DX: 403 because unauthorized.
     $url->setOption('query', ['_format' => static::$format]);
     $response = $this->request('GET', $url, $request_options);
@@ -524,13 +524,13 @@ public function testGet(): void {
     // 200 for well-formed HEAD request.
     $response = $this->request('HEAD', $url, $request_options);
     $is_cacheable_by_dynamic_page_cache = empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts()));
-    $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE');
+    $this->assertResourceResponse(200, '', $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE (poor cacheability)');
     $head_headers = $response->getHeaders();
 
     // 200 for well-formed GET request. Page Cache hit because of HEAD request.
     // Same for Dynamic Page Cache hit.
     $response = $this->request('GET', $url, $request_options);
-    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'HIT', $is_cacheable_by_dynamic_page_cache ? (static::$auth ? 'HIT' : 'MISS') : 'UNCACHEABLE');
+    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'HIT', $is_cacheable_by_dynamic_page_cache ? (static::$auth ? 'HIT' : 'MISS') : 'UNCACHEABLE (poor cacheability)');
     // Assert that Dynamic Page Cache did not store a ResourceResponse object,
     // which needs serialization after every cache hit. Instead, it should
     // contain a flattened response. Otherwise performance suffers.
@@ -633,7 +633,7 @@ public function testGet(): void {
 
     // DX: upon re-enabling a resource, immediate 200.
     $response = $this->request('GET', $url, $request_options);
-    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE');
+    $this->assertResourceResponse(200, FALSE, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), static::$auth ? FALSE : 'MISS', $is_cacheable_by_dynamic_page_cache ? 'MISS' : 'UNCACHEABLE (poor cacheability)');
 
     $this->resourceConfigStorage->load(static::$resourceConfigId)->delete();
     $this->refreshTestStateAfterRestConfigChange();
@@ -824,7 +824,7 @@ public function testPost(): void {
     else {
       $this->assertSame([], $response->getHeader('Location'));
     }
-    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    $this->assertSame([], $response->getHeader('X-Drupal-Cache-Max-Age'));
     // If the entity is stored, perform extra checks.
     if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) {
       // Assert that the entity was indeed created, and that the response body
@@ -1073,7 +1073,6 @@ public function testPatch(): void {
     // 200 for well-formed request.
     $response = $this->request('PATCH', $url, $request_options);
     $this->assertResourceResponse(200, FALSE, $response);
-    $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
     // Assert that the entity was indeed updated, and that the response body
     // contains the serialized updated entity.
     $updated_entity = $this->entityStorage->loadUnchanged($this->entity->id());
diff --git a/core/modules/rest/tests/src/Functional/ResourceTestBase.php b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
index 08a44956f41c..adc598f905ff 100644
--- a/core/modules/rest/tests/src/Functional/ResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/ResourceTestBase.php
@@ -386,8 +386,8 @@ protected function assertResourceResponse($expected_status_code, $expected_body,
       $this->assertTrue($response->hasHeader('X-Drupal-Cache'));
       $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
     }
-    else {
-      $this->assertFalse($response->hasHeader('X-Drupal-Cache'));
+    elseif ($response->hasHeader('X-Drupal-Cache')) {
+      $this->assertMatchesRegularExpression('#^UNCACHEABLE \((no cacheability|(request|response) policy)\)$#', $response->getHeader('X-Drupal-Cache')[0]);
     }
 
     // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
@@ -395,8 +395,8 @@ protected function assertResourceResponse($expected_status_code, $expected_body,
       $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
       $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
     }
-    else {
-      $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
+    elseif ($response->hasHeader('X-Drupal-Dynamic-Cache')) {
+      $this->assertMatchesRegularExpression('#^UNCACHEABLE \(((no|poor) cacheability|(request|response) policy)\)$#', $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
     }
   }
 
diff --git a/core/modules/system/tests/src/Functional/Session/SessionTest.php b/core/modules/system/tests/src/Functional/Session/SessionTest.php
index 03b695adcb82..0ae64de4e1bf 100644
--- a/core/modules/system/tests/src/Functional/Session/SessionTest.php
+++ b/core/modules/system/tests/src/Functional/Session/SessionTest.php
@@ -216,7 +216,7 @@ public function testEmptyAnonymousSession(): void {
     $this->assertSessionCookie(FALSE);
     $this->assertSessionEmpty(FALSE);
     // Verify that caching was bypassed.
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
     $this->assertSession()->pageTextContains('This is a dummy message.');
     // Verify that session cookie was deleted.
     $this->assertSession()->responseHeaderMatches('Set-Cookie', '/SESS\w+=deleted/');
diff --git a/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php b/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php
index 664fbe1c234b..9bc4cab3cf1d 100644
--- a/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php
+++ b/core/modules/system/tests/src/Functional/System/ErrorHandlerTest.php
@@ -166,7 +166,7 @@ public function testExceptionHandler(): void {
       ->save();
 
     $this->drupalGet('error-test/trigger-exception');
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (no cacheability)');
     $this->assertSession()->responseHeaderNotContains('Cache-Control', 'public');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertNoErrorMessage($error_exception);
diff --git a/core/modules/user/tests/src/Functional/UserPasswordResetTest.php b/core/modules/user/tests/src/Functional/UserPasswordResetTest.php
index b8dd558c8010..7ad09f53748b 100644
--- a/core/modules/user/tests/src/Functional/UserPasswordResetTest.php
+++ b/core/modules/user/tests/src/Functional/UserPasswordResetTest.php
@@ -122,11 +122,11 @@ public function testUserPasswordReset(): void {
     // Ensure that the current URL does not contain the hash and timestamp.
     $this->assertSession()->addressEquals(Url::fromRoute('user.reset.form', ['uid' => $this->account->id()]));
 
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
 
     // Ensure the password reset URL is not cached.
     $this->drupalGet($resetURL);
-    $this->assertSession()->responseHeaderDoesNotExist('X-Drupal-Cache');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache', 'UNCACHEABLE (request policy)');
 
     // Check the one-time login page.
     $this->assertSession()->pageTextContains($this->account->getAccountName());
diff --git a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
index be8721bbe5fa..3f67accf8a21 100644
--- a/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
+++ b/core/profiles/standard/tests/src/Traits/StandardTestTrait.php
@@ -203,7 +203,7 @@ function (ConstraintViolation $v) {
     $this->drupalGet($url);
     // Verify that site-wide contact page cannot be cached by Dynamic Page
     // Cache.
-    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE');
+    $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'UNCACHEABLE (poor cacheability)');
 
     $url = Url::fromRoute('<front>');
     $this->drupalGet($url);
-- 
GitLab