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 / integration / LogsTableTest.php

LogsTableTest.php in Custom 404 Pro trunk, at tests/integration/LogsTableTest.php

416 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Integration tests for the LogsClass list table.
4 *
5 * @package Custom_404_Pro
6 */
7
8 /**
9 * Tests LogsClass query building: search, sorting, and pagination.
10 *
11 * These run against a real MySQL database so that malformed SQL surfaces as an
12 * actual query error rather than passing silently against a stub.
13 */
14 class C404P_Integration_LogsTableTest extends WP_UnitTestCase {
15
16 /**
17 * Helpers instance used for table setup and assertions.
18 *
19 * @var Helpers
20 */
21 private $helpers;
22
23 /**
24 * Set up: create the logs table and seed a known set of rows.
25 */
26 public function setUp(): void {
27 parent::setUp();
28
29 $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
30 wp_set_current_user( $admin_id );
31
32 ActivateClass::create_tables();
33 $this->helpers = new Helpers();
34
35 // Surface any MySQL error as a test failure instead of an empty result set.
36 $GLOBALS['wpdb']->suppress_errors( false );
37 $GLOBALS['wpdb']->show_errors( false );
38
39 $_GET = array();
40 $_REQUEST = array();
41 }
42
43 /**
44 * Tear down: reset superglobals and drop the logs table.
45 */
46 public function tearDown(): void {
47 global $wpdb;
48
49 $_GET = array();
50 $_REQUEST = array();
51
52 $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . $this->helpers->table_logs ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
53
54 parent::tearDown();
55 }
56
57 /**
58 * Inserts a log row with explicit field values.
59 *
60 * @param string $ip IP address.
61 * @param string $path Request path.
62 * @param string $referer Referer URL.
63 * @param string $user_agent User agent string.
64 */
65 private function insert_log( string $ip, string $path, string $referer = '', string $user_agent = 'PHPUnit' ) {
66 global $wpdb;
67 $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
68 $wpdb->prefix . $this->helpers->table_logs,
69 array(
70 'ip' => $ip,
71 'path' => $path,
72 'referer' => $referer,
73 'user_agent' => $user_agent,
74 )
75 );
76 }
77
78 /**
79 * Inserts $count generic log rows.
80 *
81 * @param int $count Number of rows to insert.
82 */
83 private function insert_logs( int $count ) {
84 for ( $i = 0; $i < $count; $i++ ) {
85 $this->insert_log( '10.0.0.' . ( $i % 250 ), '/missing-' . $i );
86 }
87 }
88
89 /**
90 * Asserts that the last query executed did not produce a MySQL error.
91 *
92 * @param string $message Assertion message.
93 */
94 private function assertNoDbError( string $message ) { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
95 global $wpdb;
96 $this->assertSame( '', (string) $wpdb->last_error, $message . ' MySQL error: ' . $wpdb->last_error );
97 }
98
99 // -------------------------------------------------------------------------
100 // Sorting
101 // -------------------------------------------------------------------------
102
103 /**
104 * Every column advertised by get_sortable_columns() must actually sort.
105 *
106 * Regression: get_sortable_columns() returned orderby keys ('ip', 'path',
107 * 'referer', 'user_agent') that manage_sorting() did not recognise, so the
108 * bare sort direction was appended with no ORDER BY clause, producing
109 * "SELECT * FROM wp_custom_404_pro_logs ASC" — a MySQL syntax error.
110 *
111 * @dataProvider sortable_column_provider
112 * @param string $orderby The orderby query argument.
113 */
114 public function test_every_sortable_column_produces_valid_sql( string $orderby ) {
115 $this->insert_logs( 3 );
116
117 $_GET['orderby'] = $orderby;
118 $_GET['order'] = 'asc';
119
120 $table = new LogsClass();
121 $table->prepare_items();
122
123 $this->assertNoDbError( "Sorting by '{$orderby}' produced invalid SQL." );
124 $this->assertCount( 3, $table->items, "Sorting by '{$orderby}' returned no rows." );
125 }
126
127 /**
128 * Supplies every orderby key advertised as sortable.
129 *
130 * @return array<string, array<string>>
131 */
132 public function sortable_column_provider(): array {
133 $cases = array();
134 foreach ( array_keys( ( new LogsClass() )->get_sortable_columns() ) as $key ) {
135 $cases[ $key ] = array( $key );
136 }
137 return $cases;
138 }
139
140 /**
141 * Sorting descending should reverse the ordering.
142 */
143 public function test_sorting_respects_descending_direction() {
144 $this->insert_log( '10.0.0.1', '/aaa' );
145 $this->insert_log( '10.0.0.2', '/bbb' );
146 $this->insert_log( '10.0.0.3', '/ccc' );
147
148 $_GET['orderby'] = 'path';
149 $_GET['order'] = 'desc';
150
151 $table = new LogsClass();
152 $table->prepare_items();
153
154 $this->assertNoDbError( 'Descending sort produced invalid SQL.' );
155 $this->assertSame( '/ccc', $table->items[0]['path'] );
156 $this->assertSame( '/aaa', $table->items[2]['path'] );
157 }
158
159 /**
160 * An unknown orderby value must not reach the query as raw SQL.
161 */
162 public function test_unknown_orderby_is_ignored_and_does_not_break_the_query() {
163 $this->insert_logs( 3 );
164
165 $_GET['orderby'] = 'id; DROP TABLE wp_posts';
166 $_GET['order'] = 'asc';
167
168 $table = new LogsClass();
169 $table->prepare_items();
170
171 $this->assertNoDbError( 'Unknown orderby produced invalid SQL.' );
172 $this->assertCount( 3, $table->items, 'Unknown orderby should fall back to an unsorted result set.' );
173 }
174
175 /**
176 * An unknown order direction must fall back to ASC rather than be injected.
177 */
178 public function test_unknown_order_direction_falls_back_to_ascending() {
179 $this->insert_log( '10.0.0.1', '/aaa' );
180 $this->insert_log( '10.0.0.2', '/bbb' );
181
182 $_GET['orderby'] = 'path';
183 $_GET['order'] = 'sideways; DROP TABLE wp_posts';
184
185 $table = new LogsClass();
186 $table->prepare_items();
187
188 $this->assertNoDbError( 'Unknown order direction produced invalid SQL.' );
189 $this->assertSame( '/aaa', $table->items[0]['path'] );
190 }
191
192 // -------------------------------------------------------------------------
193 // Search
194 // -------------------------------------------------------------------------
195
196 /**
197 * Search should restrict results to matching rows.
198 */
199 public function test_search_filters_results() {
200 $this->insert_log( '10.0.0.1', '/wp-admin-login' );
201 $this->insert_log( '10.0.0.2', '/some-other-page' );
202
203 $_GET['s'] = 'admin-login';
204
205 $table = new LogsClass();
206 $table->prepare_items();
207
208 $this->assertNoDbError( 'Search produced invalid SQL.' );
209 $this->assertCount( 1, $table->items );
210 $this->assertSame( '/wp-admin-login', $table->items[0]['path'] );
211 }
212
213 /**
214 * Search combined with sorting must produce a valid query.
215 *
216 * Regression: prepare_items() appended ORDER BY before WHERE, yielding
217 * "SELECT * FROM t ORDER BY path ASC WHERE (...)" — a MySQL syntax error.
218 * Every search-then-sort interaction on the Logs screen hit this.
219 */
220 public function test_search_combined_with_sorting_produces_valid_sql() {
221 $this->insert_log( '10.0.0.1', '/broken-aaa' );
222 $this->insert_log( '10.0.0.2', '/broken-bbb' );
223 $this->insert_log( '10.0.0.3', '/unrelated' );
224
225 $_GET['s'] = 'broken';
226 $_GET['orderby'] = 'path';
227 $_GET['order'] = 'desc';
228
229 $table = new LogsClass();
230 $table->prepare_items();
231
232 $this->assertNoDbError( 'Search combined with sorting produced invalid SQL.' );
233 $this->assertCount( 2, $table->items, 'Search + sort should return only the matching rows.' );
234 $this->assertSame( '/broken-bbb', $table->items[0]['path'], 'Search + sort should apply the sort order.' );
235 }
236
237 /**
238 * A search term containing SQL wildcards must be treated as a literal.
239 */
240 public function test_search_escapes_like_wildcards() {
241 $this->insert_log( '10.0.0.1', '/100%-off' );
242 $this->insert_log( '10.0.0.2', '/unrelated' );
243
244 $_GET['s'] = '100%-off';
245
246 $table = new LogsClass();
247 $table->prepare_items();
248
249 $this->assertNoDbError( 'Wildcard search produced invalid SQL.' );
250 $this->assertCount( 1, $table->items );
251 }
252
253 // -------------------------------------------------------------------------
254 // Pagination
255 // -------------------------------------------------------------------------
256
257 /**
258 * Pagination must be applied in SQL, not by slicing a full table read.
259 *
260 * Regression: prepare_items() ran "SELECT *" with no LIMIT and then
261 * array_slice()'d in PHP, so rendering page 1 of a million-row log table
262 * pulled every row into memory.
263 */
264 public function test_pagination_limits_rows_read_from_the_database() {
265 $this->insert_logs( 120 );
266
267 $table = new LogsClass();
268 $table->prepare_items();
269
270 $this->assertNoDbError( 'Paginated query produced invalid SQL.' );
271 $this->assertCount( 50, $table->items, 'Page 1 should contain exactly one page of rows.' );
272
273 $last_query = $GLOBALS['wpdb']->last_query;
274 $this->assertMatchesRegularExpression(
275 '/LIMIT\s+\d+/i',
276 $last_query,
277 'The row query must apply LIMIT in SQL rather than slicing in PHP.'
278 );
279 }
280
281 /**
282 * The pagination total must reflect every matching row, not just the page.
283 */
284 public function test_pagination_total_counts_all_matching_rows() {
285 $this->insert_logs( 120 );
286
287 $table = new LogsClass();
288 $table->prepare_items();
289
290 $this->assertSame( 120, (int) $table->get_pagination_arg( 'total_items' ) );
291 $this->assertSame( 3, (int) $table->get_pagination_arg( 'total_pages' ) );
292 }
293
294 /**
295 * The pagination total must respect an active search filter.
296 */
297 public function test_pagination_total_respects_active_search() {
298 $this->insert_logs( 60 );
299 $this->insert_log( '10.0.0.1', '/uniquely-broken' );
300
301 $_GET['s'] = 'uniquely-broken';
302
303 $table = new LogsClass();
304 $table->prepare_items();
305
306 $this->assertNoDbError( 'Counting with an active search produced invalid SQL.' );
307 $this->assertSame( 1, (int) $table->get_pagination_arg( 'total_items' ) );
308 $this->assertCount( 1, $table->items );
309 }
310
311 /**
312 * Requesting page 2 should return the next slice of rows, not the first.
313 */
314 public function test_second_page_returns_different_rows_than_first_page() {
315 $this->insert_logs( 120 );
316
317 $_GET['orderby'] = 'created';
318 $_GET['order'] = 'asc';
319
320 $_REQUEST['paged'] = 1;
321 $_GET['paged'] = 1;
322 $first = new LogsClass();
323 $first->prepare_items();
324
325 $_REQUEST['paged'] = 2;
326 $_GET['paged'] = 2;
327 $second = new LogsClass();
328 $second->prepare_items();
329
330 $this->assertNoDbError( 'Page 2 query produced invalid SQL.' );
331 $this->assertCount( 50, $second->items, 'Page 2 should be a full page.' );
332 $this->assertNotSame(
333 wp_list_pluck( $first->items, 'id' ),
334 wp_list_pluck( $second->items, 'id' ),
335 'Page 2 must return a different slice of rows than page 1.'
336 );
337 }
338
339 // -------------------------------------------------------------------------
340 // Pagination stability
341 // -------------------------------------------------------------------------
342
343 /**
344 * Paging a sort whose values tie must not repeat or skip rows.
345 *
346 * `created` has one-second resolution, and a bot crawl produces dozens of
347 * 404s inside the same second. Sorting on a column with duplicate values
348 * under LIMIT/OFFSET has no defined row order in SQL unless the sort ends in
349 * something unique, so page 2 could repeat rows already shown on page 1 and
350 * silently omit others entirely.
351 */
352 public function test_paging_a_tied_sort_does_not_repeat_or_skip_rows() {
353 global $wpdb;
354
355 // 120 rows sharing one timestamp, as a burst of 404s would produce.
356 $values = array();
357 for ( $i = 0; $i < 120; $i++ ) {
358 $values[] = $wpdb->prepare( '(%s, %s, %s, %s, %s)', '10.0.0.1', '/burst-' . $i, '', 'crawler', '2026-01-01 12:00:00' );
359 }
360 $wpdb->query( 'INSERT INTO ' . $wpdb->prefix . $this->helpers->table_logs . ' (ip, path, referer, user_agent, created) VALUES ' . implode( ',', $values ) ); // phpcs:ignore
361
362 $_GET['orderby'] = 'created';
363 $_GET['order'] = 'desc';
364
365 $seen = array();
366 foreach ( array( 1, 2, 3 ) as $page ) {
367 $_GET['paged'] = $page;
368 $_REQUEST['paged'] = $page;
369 $table = new LogsClass();
370 $table->prepare_items();
371 $seen = array_merge( $seen, wp_list_pluck( $table->items, 'id' ) );
372 }
373
374 $this->assertNoDbError( 'Paging a tied sort produced invalid SQL.' );
375 $this->assertCount( 120, $seen, 'Paging must return every row exactly once across all pages.' );
376 $this->assertSame( count( $seen ), count( array_unique( $seen ) ), 'No row may appear on more than one page.' );
377 }
378
379 /**
380 * The unsorted default must also page deterministically.
381 */
382 public function test_default_unsorted_paging_does_not_repeat_or_skip_rows() {
383 $this->insert_logs( 120 );
384
385 $seen = array();
386 foreach ( array( 1, 2, 3 ) as $page ) {
387 $_GET['paged'] = $page;
388 $_REQUEST['paged'] = $page;
389 $table = new LogsClass();
390 $table->prepare_items();
391 $seen = array_merge( $seen, wp_list_pluck( $table->items, 'id' ) );
392 }
393
394 $this->assertCount( 120, $seen, 'Default paging must return every row exactly once.' );
395 $this->assertSame( count( $seen ), count( array_unique( $seen ) ), 'No row may appear on more than one page.' );
396 }
397
398 /**
399 * The row query must always carry an ORDER BY.
400 *
401 * LIMIT/OFFSET without one has no defined row order at all.
402 */
403 public function test_row_query_always_has_an_order_by() {
404 $this->insert_logs( 10 );
405
406 $table = new LogsClass();
407 $table->prepare_items();
408
409 $this->assertMatchesRegularExpression(
410 '/ORDER BY/i',
411 $GLOBALS['wpdb']->last_query,
412 'The paginated row query must always specify an ORDER BY.'
413 );
414 }
415 }
416