Skip to content

Instantly share code, notes, and snippets.

@jasonbahl
Created December 11, 2025 19:55
Show Gist options
  • Select an option

  • Save jasonbahl/7549d70947435af5e8d829acb84f35b6 to your computer and use it in GitHub Desktop.

Select an option

Save jasonbahl/7549d70947435af5e8d829acb84f35b6 to your computer and use it in GitHub Desktop.
Fix for authenticated cache leak vulnerability
diff --git a/src/Cache/Query.php b/src/Cache/Query.php
index ec5cfdc..3435ac0 100644
--- a/src/Cache/Query.php
+++ b/src/Cache/Query.php
@@ -22,6 +22,13 @@ class Query {
**/
public static $storage = null;
+ /**
+ * The current GraphQL request.
+ *
+ * @var \WPGraphQL\Request|null
+ */
+ protected $request;
+
/**
* @return void
*/
@@ -60,14 +67,15 @@ class Query {
return false;
}
- // WP_User
- $user = wp_get_current_user();
+ // Get user ID from AppContext->viewer which is set at Request creation
+ // and doesn't change even if wp_set_current_user(0) is called later
+ $user_id = $this->request->app_context->viewer->ID ?? null;
$parts = [
'query' => $query,
'variables' => $variables ?: null,
'operation' => $operation ?: null,
- 'user' => $user->ID,
+ 'user' => $user_id,
];
$parts_string = wp_json_encode( $parts );
diff --git a/src/Cache/Results.php b/src/Cache/Results.php
index 27b2e85..163e559 100644
--- a/src/Cache/Results.php
+++ b/src/Cache/Results.php
@@ -22,9 +22,14 @@ class Results extends Query {
protected $is_cached = [];
/**
- * @var \WPGraphQL\Request
+ * Stores whether the object cache is enabled.
+ *
+ * This is cached after first determination to ensure consistent behavior
+ * throughout the request lifecycle, even if WordPress auth state changes.
+ *
+ * @var bool|null
*/
- protected $request;
+ protected $is_object_cache_enabled = null;
/**
* @return void
@@ -36,9 +41,35 @@ class Results extends Query {
add_action( 'wpgraphql_cache_purge_all', [ $this, 'purge_all_cb' ], 10, 0 );
add_filter( 'graphql_request_results', [ $this, 'add_cache_key_to_response_extensions' ], 10, 7 );
+ // Set Cache-Control: no-store for authenticated requests to prevent network/CDN caching
+ add_filter( 'graphql_response_headers_to_send', [ $this, 'add_no_cache_headers_for_authenticated_requests' ], PHP_INT_MAX );
+
parent::init();
}
+ /**
+ * Add Cache-Control: no-store header for authenticated requests.
+ *
+ * This prevents network caches (Varnish, CDN, etc.) from caching responses
+ * that were made by authenticated users, which could contain sensitive data.
+ *
+ * Uses AppContext->viewer which is set at Request creation and doesn't change
+ * even if wp_set_current_user(0) is called later.
+ *
+ * @param array $headers The headers to be sent with the response.
+ *
+ * @return array The modified headers.
+ */
+ public function add_no_cache_headers_for_authenticated_requests( $headers ) {
+ // Use the viewer from AppContext, which is set at Request creation
+ // and doesn't change even if wp_set_current_user(0) is called later
+ if ( $this->request && isset( $this->request->app_context->viewer ) && $this->request->app_context->viewer->exists() ) {
+ $headers['Cache-Control'] = 'no-store';
+ }
+
+ return $headers;
+ }
+
/**
* Unique identifier for this request is normalized query string, operation and variables
*
@@ -111,6 +142,10 @@ class Results extends Query {
public function get_query_results_from_cache_cb( $result, Request $request ) {
$this->request = $request;
+ // Reset the cached is_object_cache_enabled value for each new request
+ // This ensures we re-evaluate based on the current request's auth state
+ $this->is_object_cache_enabled = null;
+
// if caching is not enabled or the request is authenticated, bail early
// right now we're not supporting GraphQL cache for authenticated requests.
// Possibly in the future.
@@ -177,6 +212,12 @@ class Results extends Query {
*/
protected function is_object_cache_enabled() {
+ // Return cached value if already determined for this request.
+ // This ensures consistent behavior even if WordPress auth state changes mid-request.
+ if ( null !== $this->is_object_cache_enabled ) {
+ return (bool) $this->is_object_cache_enabled;
+ }
+
// default to disabled
$enabled = false;
@@ -185,13 +226,21 @@ class Results extends Query {
$enabled = true;
}
- // however, if the user is logged in, we should bypass the cache
- if ( is_user_logged_in() ) {
+ // Check if the user is authenticated using AppContext->viewer.
+ // This is more reliable than is_user_logged_in() because:
+ // 1. AppContext->viewer is set once at Request creation and doesn't change
+ // 2. WPGraphQL core may call wp_set_current_user(0) mid-request in has_authentication_errors(),
+ // or even within individual resolvers, etc which would cause is_user_logged_in()
+ // to return false even for authenticated requests
+ // 3. Using AppContext is more "GraphQL-native" and consistent with how WPGraphQL handles auth
+ if ( $this->request && isset( $this->request->app_context->viewer ) && $this->request->app_context->viewer->exists() ) {
$enabled = false;
}
// @phpcs:ignore
- return (bool) apply_filters( 'graphql_cache_is_object_cache_enabled', $enabled, $this->request );
+ $this->is_object_cache_enabled = (bool) apply_filters( 'graphql_cache_is_object_cache_enabled', $enabled, $this->request );
+
+ return $this->is_object_cache_enabled;
}
/**
diff --git a/tests/acceptance.suite.yml b/tests/acceptance.suite.yml
index e030d03..d016a05 100644
--- a/tests/acceptance.suite.yml
+++ b/tests/acceptance.suite.yml
@@ -10,6 +10,7 @@ modules:
enabled:
- Asserts
- WPBrowser
+ - WPDb
- REST:
depends: PhpBrowser
part: Json
@@ -24,3 +25,10 @@ modules:
adminUsername: '%TEST_SITE_ADMIN_USERNAME%'
adminPassword: '%TEST_SITE_ADMIN_PASSWORD%'
adminPath: '/wp-admin'
+ WPDb:
+ dsn: 'mysql:host=%TEST_SITE_DB_HOST%;dbname=%TEST_SITE_DB_NAME%'
+ user: '%TEST_SITE_DB_USER%'
+ password: '%TEST_SITE_DB_PASSWORD%'
+ url: '%WP_URL%'
+ tablePrefix: '%TEST_SITE_TABLE_PREFIX%'
+ urlReplacement: false
diff --git a/tests/acceptance/AuthenticatedRequestCacheCest.php b/tests/acceptance/AuthenticatedRequestCacheCest.php
new file mode 100644
--- /dev/null
+++ b/tests/acceptance/AuthenticatedRequestCacheCest.php
@@ -0,0 +1, 173 @@
+<?php
+
+/**
+ * Test that authenticated request data does not leak to public users via cache.
+ *
+ * This uses acceptance tests with WPBrowser to properly handle authentication
+ * via the same browser session.
+ *
+ * The vulnerability was: WPGraphQL calls wp_set_current_user(0) mid-request,
+ * causing is_user_logged_in() to return false, which incorrectly cached
+ * authenticated results that could then leak to public users.
+ *
+ * The fix uses AppContext->viewer (set early, stable) instead of is_user_logged_in().
+ */
+class AuthenticatedRequestCacheCest {
+
+ /**
+ * @var int
+ */
+ protected $draft_post_id;
+
+ /**
+ * @var string
+ */
+ protected $draft_post_title;
+
+ /**
+ * @var string
+ */
+ protected $test_run_id;
+
+ public function _before( AcceptanceTester $I ) {
+ $this->test_run_id = uniqid( 'test_', true );
+ $this->draft_post_title = 'Secret Draft ' . $this->test_run_id;
+ }
+
+ /**
+ * Test that draft posts visible to admin don't leak to public users via cache.
+ *
+ * This test:
+ * 1. Logs in as admin via browser
+ * 2. Makes GraphQL request for drafts via browser URL (same session)
+ * 3. Verifies admin sees the draft
+ * 4. Makes same request via REST module (clean session, no cookies)
+ * 5. Verifies public user does NOT see the draft
+ */
+ public function testDraftContentDoesNotLeakToPublicUsers( AcceptanceTester $I ) {
+ // Enable object cache
+ $I->haveOptionInDatabase( 'graphql_cache_section', [ 'cache_toggle' => 'on' ] );
+
+ // Create draft post
+ $this->draft_post_id = $I->havePostInDatabase( [
+ 'post_type' => 'post',
+ 'post_status' => 'draft',
+ 'post_title' => $this->draft_post_title,
+ 'post_content' => 'Secret content',
+ ] );
+
+ // Build unique query
+ $operation_name = 'TestDraft_' . str_replace( '.', '_', $this->test_run_id );
+ $query = "query {$operation_name} { posts(where: {status: DRAFT}) { nodes { title status } } }";
+ $query_encoded = urlencode( $query );
+ $graphql_url = "/graphql?query={$query_encoded}";
+
+ // =====================================================
+ // STEP 1: Admin logs in and makes GraphQL request
+ // =====================================================
+ $I->loginAsAdmin();
+ $I->amOnPage( $graphql_url );
+
+ // Grab the raw page source (JSON response)
+ $auth_body = $I->grabPageSource();
+ codecept_debug( 'Authenticated response: ' . $auth_body );
+
+ $auth_response = json_decode( $auth_body, true );
+ $I->assertIsArray( $auth_response, 'Response should be valid JSON' );
+ $I->assertArrayHasKey( 'data', $auth_response, 'Response should have data key' );
+
+ // Admin should see the draft post
+ $auth_posts = $auth_response['data']['posts']['nodes'] ?? [];
+ $found_draft = false;
+ foreach ( $auth_posts as $post ) {
+ if ( $post['title'] === $this->draft_post_title ) {
+ $found_draft = true;
+ break;
+ }
+ }
+ $I->assertTrue( $found_draft, 'Authenticated admin should see the draft post' );
+
+ // Verify it was NOT served from cache (first request)
+ $auth_cache_info = $auth_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [];
+ codecept_debug( 'Auth cache info: ' . json_encode( $auth_cache_info ) );
+ $I->assertEquals( [], $auth_cache_info, 'Auth request should NOT be from cache' );
+
+ // =====================================================
+ // STEP 2: Make same request as public user via REST (clean session)
+ // =====================================================
+ // Use sendGet from REST module - this doesn't share cookies with WPBrowser
+ $I->deleteHeader( 'Authorization' );
+ $I->sendGet( 'graphql', [ 'query' => $query ] );
+ $I->seeResponseCodeIs( 200 );
+
+ $public_body = $I->grabResponse();
+ codecept_debug( 'Public response: ' . $public_body );
+
+ $public_response = json_decode( $public_body, true );
+ $I->assertIsArray( $public_response, 'Response should be valid JSON' );
+
+ // Check cache status
+ $public_cache_info = $public_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [];
+ codecept_debug( 'Public cache info: ' . json_encode( $public_cache_info ) );
+
+ // CRITICAL SECURITY CHECK: Public user should NOT see draft posts
+ $public_posts = $public_response['data']['posts']['nodes'] ?? [];
+ codecept_debug( 'Public posts count: ' . count( $public_posts ) );
+
+ foreach ( $public_posts as $post ) {
+ $I->assertNotEquals(
+ $this->draft_post_title,
+ $post['title'],
+ 'SECURITY VULNERABILITY: Public user can see draft posts!'
+ );
+ }
+
+ // Public request should NOT come from cache (auth didn't cache it)
+ $I->assertEquals(
+ [],
+ $public_cache_info,
+ 'Public request should NOT be from cache - auth request should not have cached'
+ );
+
+ // Cleanup
+ $I->dontHavePostInDatabase( [ 'ID' => $this->draft_post_id ] );
+ $I->dontHaveOptionInDatabase( 'graphql_cache_section' );
+ }
+
+ /**
+ * Test that public requests ARE properly cached.
+ */
+ public function testPublicRequestsAreCached( AcceptanceTester $I ) {
+ // Enable object cache
+ $I->haveOptionInDatabase( 'graphql_cache_section', [ 'cache_toggle' => 'on' ] );
+
+ $operation_name = 'TestPublic_' . str_replace( '.', '_', $this->test_run_id );
+ $query = urlencode( "query {$operation_name} { __typename }" );
+ $graphql_url = "/graphql?query={$query}";
+
+ // First request - NOT from cache
+ $I->amOnPage( $graphql_url );
+ $body1 = $I->grabPageSource();
+ codecept_debug( 'First response: ' . $body1 );
+
+ $response1 = json_decode( $body1, true );
+ $I->assertIsArray( $response1, 'Response should be valid JSON' );
+
+ $cache1 = $response1['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [];
+ $I->assertEquals( [], $cache1, 'First request should NOT be from cache' );
+
+ // Second request - SHOULD be from cache
+ $I->amOnPage( $graphql_url );
+ $body2 = $I->grabPageSource();
+ codecept_debug( 'Second response: ' . $body2 );
+
+ $response2 = json_decode( $body2, true );
+ $I->assertIsArray( $response2, 'Response should be valid JSON' );
+
+ $cache2 = $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [];
+ $I->assertNotEmpty( $cache2, 'Second request SHOULD be from cache' );
+
+ // Cleanup
+ $I->dontHaveOptionInDatabase( 'graphql_cache_section' );
+ }
+}
diff --git a/tests/wpunit/AuthenticatedRequestCacheTest.php b/tests/wpunit/AuthenticatedRequestCacheTest.php
new file mode 100644
--- /dev/null
+++ b/tests/wpunit/AuthenticatedRequestCacheTest.php
@@ -0,0 +1, 480 @@
+<?php
+
+namespace WPGraphQL\SmartCache;
+
+use WPGraphQL\SmartCache\Cache\Results;
+
+/**
+ * Test that authenticated requests are handled correctly by the object cache.
+ *
+ * ## Background
+ *
+ * This tests the caching behavior for authenticated vs unauthenticated requests.
+ * The object cache should only store results from unauthenticated requests to ensure
+ * that authenticated user data is not inadvertently served to public users.
+ *
+ * ## Request Execution Order in WPGraphQL\Request
+ *
+ * 1. Request is created, AppContext->viewer is set to current user (e.g., admin with ID 123)
+ * 2. before_execute() runs - stores globals, handles batch setup (NO auth checks here!)
+ * 3. Query executes, returning data based on admin's permissions (e.g., draft posts)
+ * 4. after_execute() is called
+ * 5. has_authentication_errors() is called which does:
+ * - Checks if nonce is present
+ * - If NO nonce: calls wp_set_current_user(0) to treat as unauthenticated
+ * - This has been the behavior since 2019 (see Request.php lines 355-361)
+ * 6. after_execute_actions() runs (lines 420-427)
+ * 7. 'graphql_return_response' action fires - THIS IS WHERE SMART CACHE SAVES TO CACHE
+ *
+ * ## The Challenge
+ *
+ * At step 7, if we check is_user_logged_in(), it returns FALSE because wp_set_current_user(0)
+ * was called in step 5. But the query results from step 3 contain authenticated data!
+ *
+ * So we would:
+ * - See is_user_logged_in() === false
+ * - Think "this is an unauthenticated request, safe to cache"
+ * - Cache authenticated data (draft posts, private content, etc.)
+ * - Public users then get this cached authenticated data
+ *
+ * ## Historical Context
+ *
+ * The comment in WPGraphQL core at line 408-409 says "prevent execution" but
+ * has_authentication_errors() runs in after_execute() - AFTER the query has already executed!
+ *
+ * GitHub Issue #38 (wp-graphql-jwt-authentication, July 2019)
+ * discusses this exact problem - authentication errors should halt execution BEFORE
+ * the query runs, not after. The issue suggests using the rest_authentication_errors
+ * hook pattern to abort processing early. While the issue is closed it seems it might not be fully addressed.
+ *
+ * @see https://github.com/wp-graphql/wp-graphql-jwt-authentication/issues/38
+ *
+ * ## The Solution
+ *
+ * Instead of checking is_user_logged_in() (which changes mid-request), we check
+ * AppContext->viewer which is set once at Request creation and never changes.
+ *
+ * - AppContext->viewer is set in Request constructor via WPGraphQL::get_app_context()
+ * - This captures the REAL user at request start, before any nonce checks
+ * - Even after wp_set_current_user(0) is called, AppContext->viewer still reflects
+ * the original authenticated user
+ *
+ * Additionally, we cache the result of is_object_cache_enabled() per-request to ensure
+ * consistent behavior throughout the entire request lifecycle.
+ *
+ * @see vendor/wp-graphql/wp-graphql/src/Request.php lines 355-361 (wp_set_current_user(0))
+ * @see vendor/wp-graphql/wp-graphql/src/Request.php lines 408-427 (execution order)
+ * @see vendor/wp-graphql/wp-graphql/src/WPGraphQL.php line 950 (AppContext->viewer set)
+ */
+class AuthenticatedRequestCacheTest extends \Codeception\TestCase\WPTestCase {
+
+ /**
+ * @var \WP_User
+ */
+ protected $admin_user;
+
+ /**
+ * @var int
+ */
+ protected $draft_post_id;
+
+ /**
+ * @var int
+ */
+ protected $published_post_id;
+
+ public function setUp(): void {
+ parent::setUp();
+
+ // Enable object caching
+ add_option( 'graphql_cache_section', [ 'cache_toggle' => 'on' ] );
+
+ // Create an admin user
+ $this->admin_user = self::factory()->user->create_and_get( [
+ 'role' => 'administrator',
+ ] );
+
+ // Create a draft post (only visible to authenticated users with proper capabilities)
+ $this->draft_post_id = self::factory()->post->create( [
+ 'post_type' => 'post',
+ 'post_status' => 'draft',
+ 'post_title' => 'Secret Draft Post',
+ 'post_author' => $this->admin_user->ID,
+ ] );
+
+ // Create a published post
+ $this->published_post_id = self::factory()->post->create( [
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_title' => 'Public Published Post',
+ ] );
+ }
+
+ public function tearDown(): void {
+ delete_option( 'graphql_cache_section' );
+ wp_delete_post( $this->draft_post_id, true );
+ wp_delete_post( $this->published_post_id, true );
+ wp_delete_user( $this->admin_user->ID );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test that is_object_cache_enabled returns false when AppContext viewer exists (authenticated),
+ * even if wp_set_current_user(0) was called later.
+ *
+ * This is the core of the fix - using AppContext->viewer instead of
+ * is_user_logged_in() which can change mid-request.
+ */
+ public function testCacheIsDisabledWhenAppContextViewerExists() {
+ // Log in as admin before creating the GraphQL request
+ wp_set_current_user( $this->admin_user->ID );
+
+ // Execute a simple query - this creates a Request with AppContext->viewer set to admin
+ $query = '{ __typename }';
+ $response = graphql( [ 'query' => $query ] );
+
+ // The cache should have been disabled for this authenticated request
+ $this->assertEmpty(
+ $response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Authenticated request should not be cached'
+ );
+ }
+
+ /**
+ * Test that cache IS enabled for truly unauthenticated requests.
+ */
+ public function testCacheIsEnabledForUnauthenticatedRequests() {
+ // Ensure no user is logged in
+ wp_set_current_user( 0 );
+
+ // Execute a simple query twice
+ $query = '{ __typename }';
+
+ // First request - should execute and cache
+ $response1 = graphql( [ 'query' => $query ] );
+ $this->assertArrayHasKey( 'data', $response1 );
+
+ // Second request - should be served from cache
+ $response2 = graphql( [ 'query' => $query ] );
+
+ // The second response should indicate it came from cache
+ $this->assertNotEmpty(
+ $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Second unauthenticated request should be served from cache'
+ );
+ }
+
+ /**
+ * Test the real-world scenario with draft posts.
+ *
+ * This test replicates the scenario:
+ *
+ * 1. Admin user is logged in (authenticated via WordPress session/cookie)
+ * 2. Admin makes request: /graphql/?query={posts(where:{status:DRAFT}){nodes{title status}}}
+ * 3. Admin sees draft posts in response (expected - they have permission)
+ * 4. WPGraphQL core calls wp_set_current_user(0) because no nonce was provided
+ * 5. Smart cache attempts to save results - should be BLOCKED because user WAS authenticated
+ * 6. Public user makes the same request
+ * 7. Public user should NOT see the draft posts
+ *
+ * WITHOUT THE FIX: At step 5, is_user_logged_in() returns FALSE (because of step 4),
+ * so the cache thinks it's safe to save, and public users get cached draft posts.
+ *
+ * WITH THE FIX: At step 5, we check AppContext->viewer which still reflects the admin
+ * user from step 1, so we correctly block caching.
+ */
+ public function testDraftPostsDoNotLeakFromAuthenticatedToPublicUsers() {
+ // =====================================================================
+ // STEP 1: Admin user is logged in
+ // =====================================================================
+ wp_set_current_user( $this->admin_user->ID );
+
+ // This is the exact query from the real-world scenario
+ // /graphql/?query={posts(where:{status:DRAFT}){nodes{title status}}}
+ $query = '{
+ posts(where: {status: DRAFT}) {
+ nodes {
+ title
+ status
+ }
+ }
+ }';
+
+ // =====================================================================
+ // STEP 2-3: Admin executes query and sees draft posts
+ // =====================================================================
+ $admin_response = graphql( [ 'query' => $query ] );
+
+ // Verify admin can see the draft post
+ $this->assertArrayHasKey( 'data', $admin_response );
+ $this->assertArrayHasKey( 'posts', $admin_response['data'] );
+
+ $admin_posts = $admin_response['data']['posts']['nodes'];
+ $found_draft = false;
+ foreach ( $admin_posts as $post ) {
+ if ( 'Secret Draft Post' === $post['title'] ) {
+ $found_draft = true;
+ // GraphQL returns status in lowercase
+ $this->assertEquals( 'draft', strtolower( $post['status'] ) );
+ }
+ }
+ $this->assertTrue( $found_draft, 'Admin should see the draft post' );
+
+ // =====================================================================
+ // STEP 4-5: Verify the response was NOT cached
+ // (This is where the fix prevents the issue)
+ // =====================================================================
+ $this->assertEmpty(
+ $admin_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Admin request should NOT be served from cache'
+ );
+
+ // =====================================================================
+ // STEP 6: Public user (incognito window) makes the same request
+ // =====================================================================
+ wp_set_current_user( 0 ); // Simulate unauthenticated user
+
+ $public_response = graphql( [ 'query' => $query ] );
+
+ // =====================================================================
+ // STEP 7: Verify public user does NOT see draft posts
+ // =====================================================================
+ $this->assertArrayHasKey( 'data', $public_response );
+ $public_posts = $public_response['data']['posts']['nodes'] ?? [];
+
+ // Public user should NOT see the draft post - this is the critical check
+ foreach ( $public_posts as $post ) {
+ $this->assertNotEquals(
+ 'Secret Draft Post',
+ $post['title'],
+ 'Public user should not see draft post that was visible to admin.'
+ );
+ $this->assertNotEquals(
+ 'draft',
+ strtolower( $post['status'] ),
+ 'Public user should not see draft content.'
+ );
+ }
+
+ // Also verify the public response was NOT served from a polluted cache
+ $this->assertEmpty(
+ $public_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Public request should not be served from a cache that was potentially populated by authenticated user'
+ );
+ }
+
+ /**
+ * Test that authenticated query results are NOT cached and don't leak to public users.
+ *
+ * This simulates the full flow:
+ * 1. Admin user is logged in
+ * 2. Request is created with AppContext->viewer set to admin
+ * 3. Query executes and returns draft posts
+ * 4. Cache save is attempted but should be blocked
+ * 5. Public user makes same query
+ * 6. Verify: Public user should NOT see cached authenticated data
+ */
+ public function testAuthenticatedQueryResultsAreNotCached() {
+ // Log in as admin
+ wp_set_current_user( $this->admin_user->ID );
+
+ $query = '
+ query GetDraftPosts {
+ posts(where: {status: DRAFT}) {
+ nodes {
+ id
+ title
+ status
+ }
+ }
+ }
+ ';
+
+ // Execute the query as authenticated user
+ $response = graphql( [ 'query' => $query ] );
+
+ // Verify we got the draft post in the response
+ $this->assertArrayHasKey( 'data', $response );
+ $this->assertArrayHasKey( 'posts', $response['data'] );
+
+ // Now check that the result was NOT cached
+ $this->assertEmpty(
+ $response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Authenticated request should not be from cache'
+ );
+
+ // Now log out and make the same request
+ wp_set_current_user( 0 );
+
+ $public_response = graphql( [ 'query' => $query ] );
+
+ // The public response should also NOT be from cache (because the authenticated
+ // user's response should not have been cached in the first place)
+ $this->assertEmpty(
+ $public_response['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Public request should not be served from a cache that was populated by authenticated user'
+ );
+
+ // And the public response should NOT contain the draft post
+ $public_posts = $public_response['data']['posts']['nodes'] ?? [];
+ foreach ( $public_posts as $post ) {
+ $this->assertNotEquals(
+ 'Secret Draft Post',
+ $post['title'],
+ 'Public user should NOT see draft posts that were visible to authenticated user'
+ );
+ }
+ }
+
+ /**
+ * Test that the Cache-Control header method correctly identifies authenticated requests.
+ *
+ * This ensures that network caches (Varnish, CDN) also don't cache authenticated responses.
+ * The Cache-Control: no-store header tells upstream caches not to store the response.
+ *
+ * Note: The graphql_response_headers_to_send filter only fires for HTTP requests,
+ * not internal graphql() calls. So we test the method directly.
+ */
+ public function testCacheControlHeaderSetForAuthenticatedRequests() {
+ // Log in as admin
+ wp_set_current_user( $this->admin_user->ID );
+
+ // Execute a query to set up the request on the Results instance
+ $query = '{ __typename }';
+ graphql( [ 'query' => $query ] );
+
+ // Get the Results instance and test the header method directly
+ $results = new Results();
+ $results->init();
+
+ // We need to set the request on the Results instance
+ // Use reflection to set the request property
+ $reflection = new \ReflectionClass( $results );
+ $request_property = $reflection->getProperty( 'request' );
+ $request_property->setAccessible( true );
+
+ // Create a mock request with an authenticated viewer
+ $mock_request = new \stdClass();
+ $mock_request->app_context = new \stdClass();
+ $mock_request->app_context->viewer = wp_get_current_user();
+
+ $request_property->setValue( $results, $mock_request );
+
+ // Test the header filter method directly
+ $headers = [ 'Content-Type' => 'application/json' ];
+ $filtered_headers = $results->add_no_cache_headers_for_authenticated_requests( $headers );
+
+ $this->assertArrayHasKey( 'Cache-Control', $filtered_headers, 'Cache-Control header should be set for authenticated requests' );
+ $this->assertEquals( 'no-store', $filtered_headers['Cache-Control'], 'Cache-Control should be no-store for authenticated requests' );
+ }
+
+ /**
+ * Test that Cache-Control header is NOT set to no-store for unauthenticated requests.
+ *
+ * Unauthenticated requests CAN be cached by network caches, so we should not set
+ * Cache-Control: no-store for them.
+ */
+ public function testCacheControlHeaderNotSetForUnauthenticatedRequests() {
+ // Ensure no user is logged in
+ wp_set_current_user( 0 );
+
+ // Get the Results instance and test the header method directly
+ $results = new Results();
+ $results->init();
+
+ // Use reflection to set the request property
+ $reflection = new \ReflectionClass( $results );
+ $request_property = $reflection->getProperty( 'request' );
+ $request_property->setAccessible( true );
+
+ // Create a mock request with an unauthenticated viewer
+ $mock_request = new \stdClass();
+ $mock_request->app_context = new \stdClass();
+ $mock_request->app_context->viewer = wp_get_current_user(); // Returns user with ID 0
+
+ $request_property->setValue( $results, $mock_request );
+
+ // Test the header filter method directly
+ $headers = [ 'Content-Type' => 'application/json' ];
+ $filtered_headers = $results->add_no_cache_headers_for_authenticated_requests( $headers );
+
+ // Cache-Control should NOT be set to no-store
+ $this->assertNotEquals(
+ 'no-store',
+ $filtered_headers['Cache-Control'] ?? '',
+ 'Cache-Control should not be no-store for unauthenticated requests'
+ );
+ }
+
+ /**
+ * Test that the is_object_cache_enabled result is cached after first determination.
+ *
+ * This ensures consistent behavior throughout the request even if something
+ * tries to change the auth state mid-request.
+ *
+ * The property is reset at the start of each new request (in get_query_results_from_cache_cb)
+ * to ensure each request gets a fresh evaluation based on its own auth state.
+ */
+ public function testIsObjectCacheEnabledResultIsCached() {
+ $results = new Results();
+ $results->init();
+
+ // Ensure no user is logged in
+ wp_set_current_user( 0 );
+
+ // We need to set a mock request on the Results object
+ // Since we can't easily create a full Request object, we'll test via graphql()
+ $query = '{ __typename }';
+
+ // First request as unauthenticated - should enable cache
+ $response1 = graphql( [ 'query' => $query ] );
+
+ // Second identical request should come from cache
+ $response2 = graphql( [ 'query' => $query ] );
+
+ $this->assertNotEmpty(
+ $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Second request should be served from cache, proving cache is enabled'
+ );
+ }
+
+ /**
+ * Test that multiple sequential requests with different auth states are handled correctly.
+ *
+ * This ensures the per-request reset of is_object_cache_enabled works correctly.
+ */
+ public function testSequentialRequestsWithDifferentAuthStates() {
+ $query = '{ __typename }';
+
+ // Request 1: Unauthenticated - should cache
+ wp_set_current_user( 0 );
+ $response1 = graphql( [ 'query' => $query ] );
+ $this->assertArrayHasKey( 'data', $response1 );
+
+ // Request 2: Unauthenticated again - should be from cache
+ $response2 = graphql( [ 'query' => $query ] );
+ $this->assertNotEmpty(
+ $response2['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Second unauthenticated request should be from cache'
+ );
+
+ // Request 3: Authenticated - should NOT use cache and NOT cache result
+ wp_set_current_user( $this->admin_user->ID );
+ $response3 = graphql( [ 'query' => $query ] );
+ $this->assertEmpty(
+ $response3['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Authenticated request should not be from cache'
+ );
+
+ // Request 4: Back to unauthenticated - cache should still work
+ // (the authenticated request should not have polluted the cache)
+ wp_set_current_user( 0 );
+ $response4 = graphql( [ 'query' => $query ] );
+ $this->assertNotEmpty(
+ $response4['extensions']['graphqlSmartCache']['graphqlObjectCache'] ?? [],
+ 'Unauthenticated request after authenticated request should still use cache'
+ );
+ }
+
+}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment