PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.10.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / tests / Unit / Smash1785AvatarLocalUrlGuardTest.php
reviews-feed / tests / Unit Last commit date
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