Doubles
3 weeks ago
Providers
3 weeks ago
BulkRearmOnGrowthTest.php
3 weeks ago
BulkReviewsUpdateStuckStateTest.php
3 weeks ago
ClearCacheRelayResetTest.php
3 weeks ago
DeleteSourceRelayFailureTest.php
3 weeks ago
ErrorHandlerFalsyOptionTest.php
3 weeks ago
FeedCacheUpdateServiceTest.php
3 weeks ago
FeedMalformedPayloadTest.php
3 weeks ago
ForceKeylessRefetchTest.php
3 weeks ago
LicenseDeactivateStaleStateTest.php
3 weeks ago
MediaFinderMemoTest.php
3 weeks ago
MultiSourceAggregationTest.php
3 weeks ago
ReconcileMigratedLicenseRoutineTest.php
3 weeks ago
ReconcileRemovalTest.php
3 weeks ago
RegisterWebsiteRoutineTest.php
3 weeks ago
RemoteRequestMemoTest.php
3 weeks ago
ReviewAlertHeaderTotalsTest.php
3 weeks ago
ReviewAlertPageTargetingTest.php
3 weeks ago
ReviewAlertStarFillTest.php
3 weeks ago
ShortcodeNeutralizationTest.php
3 weeks ago
SiteMigrationRecoveryTest.php
3 weeks ago
Smash1583HeaderParityTest.php
3 weeks ago
Smash1631MultiLanguageBulkTest.php
3 weeks ago
Smash1631UpdateSingleLangScopeTest.php
3 weeks ago
Smash1706TripAdvisorPlaceIdTest.php
3 weeks ago
Smash1756SchemaServiceTest.php
3 weeks ago
Smash1785AvatarLocalUrlGuardTest.php
3 weeks ago
Smash1785AvatarReHealTest.php
3 weeks ago
Smash1795ReviewTextXssTest.php
3 weeks ago
Smash782BookingHeaderRatingTest.php
3 weeks ago
Smash782CountryFlagEmojiTest.php
3 weeks ago
Smash782ExternalRefreshCronTest.php
3 weeks ago
Smash782ExtrasTemplateTest.php
3 weeks ago
Smash782ReviewAlertProviderDataTest.php
3 weeks ago
SourceIdLookupTest.php
3 weeks ago
WpmlGetCurrentLanguageTest.php
3 weeks ago
WpmlLanguageMappingTest.php
3 weeks ago
Smash1785AvatarLocalUrlGuardTest.php
316 lines
| 1 | <?php |
| 2 | |
| 3 | namespace SmashBalloon\Reviews\Tests\Unit; |
| 4 | |
| 5 | use PHPUnit\Framework\TestCase; |
| 6 | use SmashBalloon\Reviews\Pro\Parser; |
| 7 | |
| 8 | /** |
| 9 | * SMASH-1785 — a localized reviewer avatar whose URL no longer serves generated a |
| 10 | * per-visitor 404 storm that exhausted a customer's CPU. |
| 11 | * |
| 12 | * Root cause: `check_local_image()` took the URL's basename and `file_exists()`'d |
| 13 | * it in the canonical uploads folder, while the emitted URL was built from |
| 14 | * `baseurl` + the `sbr_resize_url` filter. The two bases were decoupled, so a |
| 15 | * filesystem hit could vouch for a URL that 404s (CDN/offload rewrite, migration, |
| 16 | * or a filter diverting the path). Each miss cost a full WordPress 404 page — |
| 17 | * measured at 103,846 bytes / 1.6-2.6s on the customer's host, uncacheable — at |
| 18 | * ~13 avatars per page view. |
| 19 | * |
| 20 | * The guard now requires the URL to resolve under the uploads baseurl AND the |
| 21 | * full relative path (not just the basename) to exist on disk. Anything that |
| 22 | * cannot be mapped back to a local file is reported unavailable so callers fall |
| 23 | * back to the remote avatar instead of emitting a dead URL. |
| 24 | * |
| 25 | * `test_canonical_local_url_still_resolves` is the backwards-compatibility guard: |
| 26 | * it exercises the shape every existing caller produces and must pass on both the |
| 27 | * old and new implementation. |
| 28 | */ |
| 29 | final class Smash1785AvatarLocalUrlGuardTest extends TestCase |
| 30 | { |
| 31 | /** Matches the tests/bootstrap.php wp_upload_dir() shim. */ |
| 32 | private const BASEDIR = '/tmp/uploads'; |
| 33 | private const BASEURL = 'https://example.test/wp-content/uploads'; |
| 34 | |
| 35 | /** @var string */ |
| 36 | private $avatar_dir; |
| 37 | |
| 38 | protected function setUp(): void |
| 39 | { |
| 40 | parent::setUp(); |
| 41 | |
| 42 | if (!defined('SB_COMMON_ASSETS')) { |
| 43 | define('SB_COMMON_ASSETS', 'https://example.test/wp-content/plugins/sb-reviews/vendor/smashballoon/customizer/sb-common/'); |
| 44 | } |
| 45 | |
| 46 | $this->avatar_dir = self::BASEDIR . '/sbr-feed-images/'; |
| 47 | if (!is_dir($this->avatar_dir)) { |
| 48 | mkdir($this->avatar_dir, 0777, true); |
| 49 | } |
| 50 | file_put_contents($this->avatar_dir . 'google-1785-avatar-150.png', 'png'); |
| 51 | |
| 52 | // GDPR off unless a test opts in. |
| 53 | global $wp_options_mock; |
| 54 | $wp_options_mock['sbr_settings'] = ['gdpr' => 'no']; |
| 55 | } |
| 56 | |
| 57 | protected function tearDown(): void |
| 58 | { |
| 59 | foreach (glob($this->avatar_dir . 'google-1785*') ?: [] as $f) { |
| 60 | unlink($f); |
| 61 | } |
| 62 | global $wp_options_mock; |
| 63 | $wp_options_mock = []; |
| 64 | parent::tearDown(); |
| 65 | } |
| 66 | |
| 67 | private function placeholder(): string |
| 68 | { |
| 69 | return SB_COMMON_ASSETS . 'sb-customizer/assets/images/avatar.jpg'; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * BACKWARDS COMPATIBILITY: the canonical URL every existing caller emits — |
| 74 | * baseurl + sbr-feed-images/<file> with the file present — must still be |
| 75 | * treated as a usable local image. |
| 76 | */ |
| 77 | public function test_canonical_local_url_still_resolves(): void |
| 78 | { |
| 79 | $this->assertTrue( |
| 80 | Parser::check_local_image(self::BASEURL . '/sbr-feed-images/google-1785-avatar-150.png'), |
| 81 | 'A real localized avatar under the uploads baseurl must still be served locally.' |
| 82 | ); |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * The regression itself: the file exists in the canonical folder, but the |
| 87 | * emitted URL points somewhere else (a CDN/offload rewrite or the |
| 88 | * `sbr_resize_url` filter). The old basename-only check returned true here and |
| 89 | * emitted a URL that 404s. |
| 90 | */ |
| 91 | public function test_diverted_url_is_rejected_even_though_basename_exists(): void |
| 92 | { |
| 93 | $this->assertFalse( |
| 94 | Parser::check_local_image(self::BASEURL . '/sbr-feed-images/cdn-rewritten/google-1785-avatar-150.png'), |
| 95 | 'A URL whose path does not exist on disk must not be vouched for by a basename match.' |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | public function test_foreign_host_url_is_rejected(): void |
| 100 | { |
| 101 | $this->assertFalse( |
| 102 | Parser::check_local_image('https://cdn.example.com/wp-content/uploads/sbr-feed-images/google-1785-avatar-150.png'), |
| 103 | 'A URL outside the uploads baseurl cannot be verified by file_exists().' |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | /** An http/https difference is not a foreign host — the file still serves. */ |
| 108 | public function test_scheme_mismatch_still_resolves(): void |
| 109 | { |
| 110 | $this->assertTrue( |
| 111 | Parser::check_local_image('http://example.test/wp-content/uploads/sbr-feed-images/google-1785-avatar-150.png'), |
| 112 | 'Only the scheme differs from baseurl; the local file should still be used.' |
| 113 | ); |
| 114 | } |
| 115 | |
| 116 | public function test_missing_file_under_baseurl_is_rejected(): void |
| 117 | { |
| 118 | $this->assertFalse( |
| 119 | Parser::check_local_image(self::BASEURL . '/sbr-feed-images/google-1785-does-not-exist-150.png') |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | public function test_traversal_is_rejected(): void |
| 124 | { |
| 125 | $this->assertFalse( |
| 126 | Parser::check_local_image(self::BASEURL . '/sbr-feed-images/../../../etc/passwd') |
| 127 | ); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * @dataProvider emptyValueProvider |
| 132 | * @param mixed $value |
| 133 | */ |
| 134 | public function test_empty_and_non_string_values_are_rejected($value): void |
| 135 | { |
| 136 | $this->assertFalse(Parser::check_local_image($value)); |
| 137 | } |
| 138 | |
| 139 | public static function emptyValueProvider(): array |
| 140 | { |
| 141 | return [ |
| 142 | 'empty string' => [''], |
| 143 | 'null' => [null], |
| 144 | 'false' => [false], |
| 145 | 'array' => [[]], |
| 146 | ]; |
| 147 | } |
| 148 | |
| 149 | // ---- fallback URL used by the template's onerror chain ---- |
| 150 | |
| 151 | /** |
| 152 | * The case the template's onerror chain exists for: the local file passes the |
| 153 | * server-side check (so the local URL is emitted) but dies in the browser — |
| 154 | * the file was removed after the HTML was generated, or stale cached HTML is |
| 155 | * being served. The next thing to try is the remote avatar. |
| 156 | */ |
| 157 | public function test_fallback_is_remote_avatar_when_not_doing_gdpr(): void |
| 158 | { |
| 159 | $parser = new Parser(); |
| 160 | $post = [ |
| 161 | 'reviewer' => [ |
| 162 | 'avatar' => 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 163 | 'avatar_local' => self::BASEURL . '/sbr-feed-images/google-1785-avatar-150.png', |
| 164 | ], |
| 165 | ]; |
| 166 | |
| 167 | $this->assertSame( |
| 168 | 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 169 | $parser->get_reviewer_avatar_fallback_url($post) |
| 170 | ); |
| 171 | } |
| 172 | |
| 173 | /** GDPR must never let the fallback reach out to the remote avatar. */ |
| 174 | public function test_fallback_is_placeholder_under_gdpr(): void |
| 175 | { |
| 176 | global $wp_options_mock; |
| 177 | $wp_options_mock['sbr_settings'] = ['gdpr' => 'yes']; |
| 178 | |
| 179 | $parser = new Parser(); |
| 180 | $post = [ |
| 181 | 'reviewer' => [ |
| 182 | 'avatar' => 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 183 | 'avatar_local' => self::BASEURL . '/sbr-feed-images/google-1785-avatar-150.png', |
| 184 | ], |
| 185 | ]; |
| 186 | |
| 187 | $this->assertSame( |
| 188 | $this->placeholder(), |
| 189 | $parser->get_reviewer_avatar_fallback_url($post), |
| 190 | 'Under GDPR the remote avatar stays suppressed.' |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | /** Nothing left to try when the primary is already the placeholder. */ |
| 195 | public function test_fallback_is_empty_when_primary_is_placeholder(): void |
| 196 | { |
| 197 | $parser = new Parser(); |
| 198 | |
| 199 | $this->assertSame('', $parser->get_reviewer_avatar_fallback_url(['reviewer' => []])); |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * When the local file is genuinely gone the primary URL is already the remote |
| 204 | * avatar, so the fallback must move on to the placeholder rather than repeat it. |
| 205 | */ |
| 206 | public function test_fallback_is_placeholder_when_primary_is_already_remote(): void |
| 207 | { |
| 208 | $parser = new Parser(); |
| 209 | $post = [ |
| 210 | 'reviewer' => [ |
| 211 | 'avatar' => 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 212 | 'avatar_local' => '', |
| 213 | ], |
| 214 | ]; |
| 215 | |
| 216 | $this->assertSame($this->placeholder(), $parser->get_reviewer_avatar_fallback_url($post)); |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * `get_reviewer_avatar_url()` now checks `should_store_local_images()` like its two |
| 221 | * siblings (`get_author_remote_avatar()`, `get_media_url()`) so the rendered image |
| 222 | * and `data-image-url` can never disagree about whether local images are in use. |
| 223 | * |
| 224 | * Whatever the setting, the two accessors must return the same thing for a healthy |
| 225 | * local avatar. |
| 226 | */ |
| 227 | public function test_rendered_avatar_and_data_image_url_agree(): void |
| 228 | { |
| 229 | $parser = new Parser(); |
| 230 | $post = [ |
| 231 | 'reviewer' => [ |
| 232 | 'avatar' => 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 233 | 'avatar_local' => self::BASEURL . '/sbr-feed-images/google-1785-avatar-150.png', |
| 234 | ], |
| 235 | ]; |
| 236 | |
| 237 | foreach ([true, false] as $optimize) { |
| 238 | global $wp_options_mock; |
| 239 | $wp_options_mock['sbr_settings'] = ['gdpr' => 'no', 'optimize_images' => $optimize]; |
| 240 | |
| 241 | $this->assertSame( |
| 242 | $parser->get_author_remote_avatar($post), |
| 243 | $parser->get_reviewer_avatar_url($post), |
| 244 | 'The rendered image and data-image-url must not diverge (optimize_images=' |
| 245 | . var_export($optimize, true) . ').' |
| 246 | ); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /** |
| 251 | * KNOWN SEPARATE BUG, pinned deliberately: `should_store_local_images()` can never |
| 252 | * return false. |
| 253 | * |
| 254 | * `Util::should_store_local_images()` ends in |
| 255 | * `!empty($settings['optimize_images']) ? $settings['optimize_images'] : true`, so |
| 256 | * every falsy value — false, 0, '0', '', missing — falls through to `true`. |
| 257 | * Verified against a live install: all six inputs returned true. |
| 258 | * |
| 259 | * Consequence: the `should_store_local_images()` guard in all three Parser |
| 260 | * accessors is currently dead code, and turning "optimize images" off does not stop |
| 261 | * local avatars or media being used. That is a behaviour-changing fix (sites with |
| 262 | * the setting off would start requesting remote images, and would fall to the |
| 263 | * placeholder under GDPR), so it is intentionally NOT part of SMASH-1785. |
| 264 | * |
| 265 | * When someone fixes the helper, this test fails and points at the accessors that |
| 266 | * become live — which is exactly the reminder that is wanted. |
| 267 | */ |
| 268 | public function test_should_store_local_images_currently_always_true(): void |
| 269 | { |
| 270 | global $wp_options_mock; |
| 271 | |
| 272 | foreach ([true, false, 0, '0', '', null] as $value) { |
| 273 | $wp_options_mock['sbr_settings'] = ['optimize_images' => $value]; |
| 274 | |
| 275 | $this->assertTrue( |
| 276 | (bool) \SmashBalloon\Reviews\Common\Util::should_store_local_images(), |
| 277 | 'Documented dead-code behaviour changed for optimize_images=' |
| 278 | . var_export($value, true) . ' — see this test\'s docblock before "fixing" it.' |
| 279 | ); |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | /** The primary URL still prefers the local file when it genuinely serves. */ |
| 284 | public function test_primary_prefers_valid_local_avatar(): void |
| 285 | { |
| 286 | $parser = new Parser(); |
| 287 | $local = self::BASEURL . '/sbr-feed-images/google-1785-avatar-150.png'; |
| 288 | $post = [ |
| 289 | 'reviewer' => [ |
| 290 | 'avatar' => 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 291 | 'avatar_local' => $local, |
| 292 | ], |
| 293 | ]; |
| 294 | |
| 295 | $this->assertSame($local, $parser->get_reviewer_avatar_url($post)); |
| 296 | } |
| 297 | |
| 298 | /** ...and falls back to the remote one when the local URL cannot be trusted. */ |
| 299 | public function test_primary_falls_back_to_remote_when_local_url_is_dead(): void |
| 300 | { |
| 301 | $parser = new Parser(); |
| 302 | $post = [ |
| 303 | 'reviewer' => [ |
| 304 | 'avatar' => 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 305 | 'avatar_local' => self::BASEURL . '/sbr-feed-images/cdn-rewritten/google-1785-avatar-150.png', |
| 306 | ], |
| 307 | ]; |
| 308 | |
| 309 | $this->assertSame( |
| 310 | 'https://lh3.googleusercontent.com/a/real-remote=s120', |
| 311 | $parser->get_reviewer_avatar_url($post), |
| 312 | 'A dead local URL must never be emitted to the browser.' |
| 313 | ); |
| 314 | } |
| 315 | } |
| 316 |