Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions tests/php/WpscParseAcceptHeaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php
/**
* Tests for wpsc_parse_accept_header().
*
* @package automattic/wp-super-cache
*/

// wpsc_parse_accept_header() has no WordPress dependencies — include only the
// containing file. wp_cache_debug() is defined inside wp-cache-phase2.php and
// returns early when its globals are unset, so no stub is needed. apply_filters()
// is a WP core function not defined in the file; stub it for completeness.
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( $tag, $value ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement
return $value;
}
}

require_once dirname( __DIR__, 2 ) . '/wp-cache-phase2.php';

use PHPUnit\Framework\TestCase;

/**
* @covers wpsc_parse_accept_header
*/
class WpscParseAcceptHeaderTest extends TestCase {

/** Default JSON types mirroring the wpsc_accept_headers default. */
private array $json_types = array(
'application/json',
'application/activity+json',
'application/ld+json',
);

// ── RFC 7231 acceptance criteria from issue #1045 ─────────────────────────

/**
* New Relic Synthetics header: text/html has implicit q=1.0, JSON is
* deprioritised at q=0.9 — must classify as text/html.
*/
public function test_nr_synthetics_header_classifies_as_html(): void {
$accept = 'text/html,application/xhtml+xml,application/json;q=0.9,application/javascript;q=0.9,text/javascript;q=0.9,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7';
$this->assertSame( 'text/html', wpsc_parse_accept_header( $accept, $this->json_types ) );
}

/** Bare JSON-only Accept: application/json should classify as JSON. */
public function test_bare_json_classifies_as_json(): void {
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json', $this->json_types ) );
}

/** Tie (both implicit q=1.0) resolves to text/html (safe default). */
public function test_tie_resolves_to_html(): void {
$this->assertSame( 'text/html', wpsc_parse_accept_header( 'text/html,application/json', $this->json_types ) );
}

/** JSON strictly higher q than text/html must classify as JSON. */
public function test_json_higher_q_classifies_as_json(): void {
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json,text/html;q=0.9', $this->json_types ) );
}

/** Extended JSON type (application/ld+json) via filter participates in comparison. */
public function test_extended_json_type_participates(): void {
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/ld+json;q=1.0,text/html;q=0.8', $this->json_types ) );
}

/** Malformed q-value (non-numeric) treated as q=1.0, no warning/fatal. */
public function test_malformed_q_value_treated_as_default(): void {
// application/json;q=bad → treated as q=1.0; text/html is absent → effective html_q from */*=0.7.
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json;q=bad,*/*;q=0.7', $this->json_types ) );
}

/** Out-of-range q-value clamped — q=2 treated as 1.0. */
public function test_out_of_range_q_clamped(): void {
$this->assertSame( 'text/html', wpsc_parse_accept_header( 'text/html;q=2,application/json;q=0.9', $this->json_types ) );
}

// ── Wildcard behaviour ────────────────────────────────────────────────────

// Wildcard (*/*) covers text/html when text/html is not explicitly listed.
public function test_wildcard_covers_html_when_not_explicit(): void {
// */* q=1.0 > json q=0.9 → text/html.
$this->assertSame( 'text/html', wpsc_parse_accept_header( '*/*,application/json;q=0.9', $this->json_types ) );
}

// Explicit text/html;q=0.5 wins over */*;q=1.0 — wildcard does not boost an explicit html q.
public function test_explicit_html_takes_precedence_over_wildcard(): void {
// html explicit q=0.5; wildcard q=1.0; json q=0.8 → 0.8 > 0.5 → application/json.
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'text/html;q=0.5,*/*;q=1.0,application/json;q=0.8', $this->json_types ) );
}

// No text/html and no wildcard, but JSON present — effective html_q = 0.0 → application/json.
public function test_no_html_no_wildcard_json_present(): void {
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/json;q=0.5', $this->json_types ) );
}

// ── Edge cases ────────────────────────────────────────────────────────────

/** Whitespace around media types is handled. */
public function test_whitespace_around_media_types(): void {
$this->assertSame( 'text/html', wpsc_parse_accept_header( ' text/html , application/json;q=0.9 ', $this->json_types ) );
}

/** application/activity+json classified as JSON. */
public function test_activity_json_classified_as_json(): void {
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/activity+json', $this->json_types ) );
}

/** Custom type added via wpsc_accept_headers filter participates. */
public function test_custom_json_type_via_filter_participates(): void {
$extended = array_merge( $this->json_types, array( 'application/vnd.api+json' ) );
$this->assertSame( 'application/json', wpsc_parse_accept_header( 'application/vnd.api+json;q=1.0,text/html;q=0.8', $extended ) );
}

/** Typical browser Accept header classifies as text/html. */
public function test_typical_browser_accept_header(): void {
$accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8';
$this->assertSame( 'text/html', wpsc_parse_accept_header( $accept, $this->json_types ) );
}
}
72 changes: 63 additions & 9 deletions wp-cache-phase2.php
Original file line number Diff line number Diff line change
Expand Up @@ -537,23 +537,77 @@ function wpsc_get_accept_header() {
$accept_headers = apply_filters( 'wpsc_accept_headers', array( 'application/json', 'application/activity+json', 'application/ld+json' ) );
$accept_headers = array_map( 'strtolower', $accept_headers );
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $accept is checked and set below.
$accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( filter_var( $_SERVER['HTTP_ACCEPT'] ) ) : '';
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $raw is checked and set below.
$raw = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( filter_var( $_SERVER['HTTP_ACCEPT'] ) ) : '';

foreach ( $accept_headers as $header ) {
if ( str_contains( $accept, $header ) ) {
$accept = 'application/json';
$accept = empty( $raw ) ? 'text/html' : wpsc_parse_accept_header( $raw, $accept_headers );

wp_cache_debug( 'ACCEPT: ' . $accept );
}

return $accept;
}

/**
* Classify an Accept header as 'text/html' or 'application/json'.
*
* Parses RFC 7231 §5.3.2 quality values (q=) so that a request where
* text/html has a higher or equal q-value than any known JSON type is
* correctly served from cache. The previous str_contains() approach treated
* any mention of a JSON media type as a JSON request, regardless of priority.
*
* Classification rules:
* - Classify as 'application/json' only when a known JSON type has a
* strictly higher q-value than text/html.
* - Ties, or text/html strictly higher, resolve to 'text/html'.
* - If text/html is absent, its effective q-value is taken from *\/* (if
* present). If neither is present, the effective html q-value is 0.0.
* - Malformed q-values (non-numeric, out-of-range) are treated as q=1.0.
*
* @param string $raw_accept Lowercased, non-empty Accept header value.
* @param string[] $json_types Media types to treat as JSON (from wpsc_accept_headers filter).
* @return string 'text/html' or 'application/json'.
*/
function wpsc_parse_accept_header( $raw_accept, $json_types ) {
$html_q = null; // null = not explicitly present in Accept header.
$wildcard_q = null;
$json_q = 0.0;

foreach ( explode( ',', $raw_accept ) as $part ) {
$segments = explode( ';', trim( $part ) );
$media_type = trim( $segments[0] );
$q = 1.0; // Default q-value per RFC 7231 §5.3.1.

for ( $i = 1, $len = count( $segments ); $i < $len; $i++ ) {
$param = ltrim( $segments[ $i ] );
if ( strncmp( $param, 'q=', 2 ) === 0 ) {
$q_str = substr( $param, 2 );
$q = is_numeric( $q_str ) ? max( 0.0, min( 1.0, (float) $q_str ) ) : 1.0;
break;
}
}

if ( $accept !== 'application/json' ) {
$accept = 'text/html';
if ( 'text/html' === $media_type ) {
$html_q = null === $html_q ? $q : max( $html_q, $q );
} elseif ( '*/*' === $media_type ) {
$wildcard_q = null === $wildcard_q ? $q : max( $wildcard_q, $q );
}

wp_cache_debug( 'ACCEPT: ' . $accept );
if ( in_array( $media_type, $json_types, true ) ) {
$json_q = max( $json_q, $q );
}
}

return $accept;
// Effective html q: explicit text/html wins; fall back to */* if absent.
if ( null !== $html_q ) {
$effective_html_q = $html_q;
} elseif ( null !== $wildcard_q ) {
$effective_html_q = $wildcard_q;
} else {
$effective_html_q = 0.0;
}

return $json_q > $effective_html_q ? 'application/json' : 'text/html';
}

function wp_cache_get_cookies_values() {
Expand Down