1<?php 2 3namespace dokuwiki\plugin\statistics\test; 4 5use dokuwiki\plugin\statistics\Logger; 6use DokuWikiTest; 7use helper_plugin_statistics; 8 9/** 10 * Tests for the statistics plugin Logger class 11 * 12 * @group plugin_statistics 13 * @group plugins 14 */ 15class LoggerTest extends DokuWikiTest 16{ 17 protected $pluginsEnabled = ['statistics', 'sqlite']; 18 19 /** @var helper_plugin_statistics */ 20 protected $helper; 21 22 const SESSION_ID = 'test-session-12345'; 23 const USER_ID = 'test-uid-12345'; 24 const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; 25 26 public function setUp(): void 27 { 28 parent::setUp(); 29 30 // Load the helper plugin 31 $this->helper = plugin_load('helper', 'statistics'); 32 33 // set default user agent 34 $_SERVER['HTTP_USER_AGENT'] = self::USER_AGENT; 35 36 // Set up session data that Logger expects 37 $_SESSION[DOKU_COOKIE]['statistics']['uid'] = self::USER_ID; 38 $_SESSION[DOKU_COOKIE]['statistics']['id'] = self::SESSION_ID; 39 } 40 41 public function tearDown(): void 42 { 43 unset($_SERVER['HTTP_USER_AGENT']); 44 unset($_SESSION[DOKU_COOKIE]['statistics']); 45 parent::tearDown(); 46 } 47 48 /** 49 * Test constructor initializes properties correctly 50 */ 51 public function testConstructor() 52 { 53 $this->assertInstanceOf(Logger::class, $this->helper->getLogger()); 54 55 // Test that bot user agents throw exception 56 $_SERVER['HTTP_USER_AGENT'] = 'Googlebot/2.1 (+http://www.google.com/bot.html)'; 57 58 $this->expectException(\dokuwiki\plugin\statistics\IgnoreException::class); 59 $this->expectExceptionMessage('Bot detected, not logging'); 60 new Logger($this->helper); 61 } 62 63 /** 64 * Test begin and end transaction methods 65 */ 66 public function testBeginEnd() 67 { 68 $this->helper->getLogger()->begin(); 69 70 // Verify transaction is active by checking PDO 71 $pdo = $this->helper->getDB()->getPdo(); 72 $this->assertTrue($pdo->inTransaction()); 73 74 $this->helper->getLogger()->end(); 75 76 // Verify transaction is committed 77 $this->assertFalse($pdo->inTransaction()); 78 } 79 80 /** 81 * Test user logging 82 */ 83 public function testLogUser() 84 { 85 // Test with no user (should not log) 86 $_SERVER['REMOTE_USER'] = ''; 87 $this->helper->getLogger()->begin(); 88 $this->helper->getLogger()->end(); 89 90 $count = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM users'); 91 $this->assertEquals(0, $count); 92 93 // Test with user 94 $_SERVER['REMOTE_USER'] = 'testuser'; 95 $this->helper->getLogger()->begin(); 96 $this->helper->getLogger()->end(); 97 98 $count = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM users'); 99 $this->assertEquals(1, $count); 100 101 $user = $this->helper->getDB()->queryValue('SELECT user FROM users WHERE user = ?', ['testuser']); 102 $this->assertEquals('testuser', $user); 103 } 104 105 /** 106 * Data provider for logGroups test 107 */ 108 public function logGroupsProvider() 109 { 110 return [ 111 'empty groups' => [[], 0], 112 'single group' => [['admin'], 1], 113 'multiple groups' => [['admin', 'user'], 2], 114 'filtered groups' => [['admin', 'nonexistent'], 2], // all groups are logged 115 ]; 116 } 117 118 /** 119 * Test logGroups method 120 * @dataProvider logGroupsProvider 121 */ 122 public function testLogGroups($groups, $expectedCount) 123 { 124 global $USERINFO; 125 126 // Set up a test user and groups 127 $_SERVER['REMOTE_USER'] = 'testuser'; 128 $USERINFO = ['grps' => $groups]; 129 130 131 $this->helper->getLogger()->begin(); 132 $this->helper->getLogger()->end(); 133 134 $count = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM groups WHERE user = ?', ['testuser']); 135 $this->assertEquals($expectedCount, $count); 136 137 if ($expectedCount > 0) { 138 $loggedGroups = $this->helper->getDB()->queryAll('SELECT `group` FROM groups WHERE user = ?', ['testuser']); 139 $this->assertCount($expectedCount, $loggedGroups); 140 } 141 } 142 143 /** 144 * Data provider for testLogReferer test 145 */ 146 public function logRefererProvider() 147 { 148 return [ 149 'google search' => [ 150 'https://www.google.com/search?q=dokuwiki+test', 151 'google', 152 true // should be logged 153 ], 154 'bing search' => [ 155 'https://www.bing.com/search?q=test+query', 156 'bing', 157 true // should be logged 158 ], 159 'external referer' => [ 160 'https://example.com/page', 161 null, 162 true // should be logged 163 ], 164 'direct access (empty referer)' => [ 165 '', 166 null, 167 true // should be logged 168 ], 169 'ws' => [ 170 ' ', 171 null, 172 true // should be logged (trimmed to empty) 173 ], 174 ]; 175 } 176 177 /** 178 * Test logReferer method 179 * @dataProvider logRefererProvider 180 */ 181 public function testLogReferer($referer, $expectedEngine, $shouldBeLogged) 182 { 183 $logger = $this->helper->getLogger(); 184 $logger->begin(); 185 $refId = $logger->logReferer($referer); 186 $logger->end(); 187 188 if ($shouldBeLogged) { 189 $this->assertNotNull($refId); 190 $refererRecord = $this->helper->getDB()->queryRecord('SELECT * FROM referers WHERE id = ?', [$refId]); 191 $this->assertNotNull($refererRecord); 192 $this->assertEquals($expectedEngine, $refererRecord['engine']); 193 $this->assertEquals(trim($referer), $refererRecord['url']); 194 } else { 195 $this->assertNull($refId); 196 } 197 } 198 199 /** 200 * Test that internal referers (our own pages) are not logged 201 */ 202 public function testLogRefererInternal() 203 { 204 // Test internal referer (should return null and not be logged) 205 $internalReferer = DOKU_URL; 206 $refId = $this->helper->getLogger()->logReferer($internalReferer); 207 $this->assertNull($refId, 'Internal referers should not be logged'); 208 209 // Verify no referer was actually stored 210 $count = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM referers WHERE url = ?', [$internalReferer]); 211 $this->assertEquals(0, $count, 'Internal referer should not be stored in database'); 212 213 // Test another internal referer pattern 214 $internalReferer2 = rtrim(DOKU_URL, '/') . '/doku.php?id=start'; 215 $refId2 = $this->helper->getLogger()->logReferer($internalReferer2); 216 $this->assertNull($refId2, 'Internal wiki pages should not be logged as referers'); 217 } 218 219 /** 220 * Test logSearch method 221 */ 222 public function testLogSearch() 223 { 224 $query = 'test search query'; 225 $words = ['test', 'search', 'query']; 226 227 $this->helper->getLogger()->logSearch($query, $words); 228 229 // Check search table 230 $search = $this->helper->getDB()->queryRecord('SELECT * FROM search ORDER BY dt DESC LIMIT 1'); 231 $this->assertEquals($query, $search['query']); 232 233 // Check searchwords table 234 $wordCount = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM searchwords WHERE sid = ?', [$search['id']]); 235 $this->assertEquals(3, $wordCount); 236 237 $loggedWords = $this->helper->getDB()->queryAll('SELECT word FROM searchwords WHERE sid = ? ORDER BY word', [$search['id']]); 238 $this->assertEquals(['query', 'search', 'test'], array_column($loggedWords, 'word')); 239 } 240 241 /** 242 * Test logSession method 243 */ 244 public function testLogSession() 245 { 246 $_SERVER['REMOTE_USER'] = 'testuser'; 247 248 // Test session creation 249 $logger = $this->helper->getLogger(); 250 251 $logger->begin(); 252 $logger->end(); 253 254 $sessionCount = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM sessions'); 255 $this->assertEquals(1, $sessionCount); 256 257 $session = $this->helper->getDB()->queryRecord('SELECT * FROM sessions LIMIT 1'); 258 $this->assertIsArray($session); 259 $this->assertEquals('testuser', $session['user']); 260 $this->assertEquals(self::SESSION_ID, $session['session']); 261 $this->assertEquals(self::USER_ID, $session['uid']); 262 $this->assertEquals(self::USER_AGENT, $session['ua']); 263 $this->assertEquals('Chrome', $session['ua_info']); 264 $this->assertEquals('browser', $session['ua_type']); 265 $this->assertEquals('91', $session['ua_ver']); 266 $this->assertEquals('Windows', $session['os']); 267 268 } 269 270 /** 271 * Test logIp method 272 */ 273 public function testLogIp() 274 { 275 $ip = '8.8.8.8'; 276 $_SERVER['REMOTE_ADDR'] = $ip; 277 278 // Create a mock HTTP client 279 $mockHttpClient = $this->createMock(\dokuwiki\HTTP\DokuHTTPClient::class); 280 281 // Mock the API response 282 $mockResponse = json_encode([ 283 'status' => 'success', 284 'country' => 'United States', 285 'countryCode' => 'US', 286 'city' => 'Ashburn', 287 'query' => $ip 288 ]); 289 290 $mockHttpClient->expects($this->once()) 291 ->method('get') 292 ->with('http://ip-api.com/json/' . $ip) 293 ->willReturn($mockResponse); 294 295 // Set timeout property 296 $mockHttpClient->timeout = 10; 297 298 // Create logger with mock HTTP client 299 $this->helper->httpClient = $mockHttpClient; 300 $logger = new Logger($this->helper); 301 302 // Test with IP that doesn't exist in database 303 $logger->logIp(); 304 305 // Verify the IP was logged 306 $ipRecord = $this->helper->getDB()->queryRecord('SELECT * FROM iplocation WHERE ip = ?', [$ip]); 307 $this->assertNotNull($ipRecord); 308 $this->assertEquals($ip, $ipRecord['ip']); 309 $this->assertEquals('United States', $ipRecord['country']); 310 $this->assertEquals('US', $ipRecord['code']); 311 $this->assertEquals('Ashburn', $ipRecord['city']); 312 $this->assertNotEmpty($ipRecord['host']); // gethostbyaddr result 313 314 // Test with IP that already exists and is recent (should not make HTTP call) 315 $mockHttpClient2 = $this->createMock(\dokuwiki\HTTP\DokuHTTPClient::class); 316 $mockHttpClient2->expects($this->never())->method('get'); 317 318 $this->helper->httpClient = $mockHttpClient2; 319 $logger2 = new Logger($this->helper); 320 $logger2->logIp(); // Should not trigger HTTP call 321 322 $this->helper->httpClient = null; // Reset HTTP client for other tests 323 } 324 325 /** 326 * Test logOutgoing method 327 */ 328 public function testLogOutgoing() 329 { 330 global $INPUT; 331 332 // Test without outgoing link 333 $INPUT->set('ol', ''); 334 $this->helper->getLogger()->logOutgoing(); 335 336 $count = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM outlinks'); 337 $this->assertEquals(0, $count); 338 339 // Test with outgoing link 340 $link = 'https://example.com'; 341 $page = 'test:page'; 342 $INPUT->set('ol', $link); 343 $INPUT->set('p', $page); 344 345 $this->helper->getLogger()->logOutgoing(); 346 347 $count = $this->helper->getDB()->queryValue('SELECT COUNT(*) FROM outlinks'); 348 $this->assertEquals(1, $count); 349 350 $outlink = $this->helper->getDB()->queryRecord('SELECT * FROM outlinks ORDER BY dt DESC LIMIT 1'); 351 $this->assertEquals($link, $outlink['link']); 352 $this->assertEquals($page, $outlink['page']); 353 } 354 355 /** 356 * Test logPageView method 357 */ 358 public function testLogPageView() 359 { 360 global $INPUT, $USERINFO, $conf; 361 362 $conf['plugin']['statistics']['loggroups'] = ['admin', 'user']; 363 364 $page = 'test:page'; 365 $referer = 'https://example.com'; 366 $user = 'testuser'; 367 368 $INPUT->set('p', $page); 369 $INPUT->set('r', $referer); 370 $INPUT->set('sx', 1920); 371 $INPUT->set('sy', 1080); 372 $INPUT->set('vx', 1200); 373 $INPUT->set('vy', 800); 374 $INPUT->server->set('REMOTE_USER', $user); 375 376 $USERINFO = ['grps' => ['admin', 'user']]; 377 378 $logger = $this->helper->getLogger(); 379 $logger->begin(); 380 $logger->logPageView(); 381 $logger->end(); 382 383 // Check pageviews table 384 $pageview = $this->helper->getDB()->queryRecord('SELECT * FROM pageviews ORDER BY dt DESC LIMIT 1'); 385 $this->assertEquals($page, $pageview['page']); 386 $this->assertEquals(1920, $pageview['screen_x']); 387 $this->assertEquals(1080, $pageview['screen_y']); 388 $this->assertEquals(1200, $pageview['view_x']); 389 $this->assertEquals(800, $pageview['view_y']); 390 $this->assertEquals(self::SESSION_ID, $pageview['session']); 391 } 392 393 /** 394 * Data provider for logMedia test 395 */ 396 public function logMediaProvider() 397 { 398 return [ 399 'image inline' => ['test.jpg', 'image/jpeg', true, 1024], 400 'video not inline' => ['test.mp4', 'video/mp4', false, 2048], 401 'document' => ['test.pdf', 'application/pdf', false, 512], 402 ]; 403 } 404 405 /** 406 * Test logMedia method 407 * @dataProvider logMediaProvider 408 */ 409 public function testLogMedia($media, $mime, $inline, $size) 410 { 411 global $INPUT; 412 413 $user = 'testuser'; 414 $INPUT->server->set('REMOTE_USER', $user); 415 416 $this->helper->getLogger()->logMedia($media, $mime, $inline, $size); 417 418 $mediaLog = $this->helper->getDB()->queryRecord('SELECT * FROM media ORDER BY dt DESC LIMIT 1'); 419 $this->assertEquals($media, $mediaLog['media']); 420 $this->assertEquals($size, $mediaLog['size']); 421 $this->assertEquals($inline ? 1 : 0, $mediaLog['inline']); 422 423 [$mime1, $mime2] = explode('/', strtolower($mime)); 424 $this->assertEquals($mime1, $mediaLog['mime1']); 425 $this->assertEquals($mime2, $mediaLog['mime2']); 426 } 427 428 /** 429 * Data provider for logEdit test 430 */ 431 public function logEditProvider() 432 { 433 return [ 434 'create page' => ['new:page', 'create'], 435 'edit page' => ['existing:page', 'edit'], 436 'delete page' => ['old:page', 'delete'], 437 ]; 438 } 439 440 /** 441 * Test logEdit method 442 * @dataProvider logEditProvider 443 */ 444 public function testLogEdit($page, $type) 445 { 446 global $INPUT, $USERINFO; 447 448 449 $user = 'testuser'; 450 $INPUT->server->set('REMOTE_USER', $user); 451 $USERINFO = ['grps' => ['admin']]; 452 453 $this->helper->getLogger()->logEdit($page, $type); 454 455 // Check edits table 456 $edit = $this->helper->getDB()->queryRecord('SELECT * FROM edits ORDER BY dt DESC LIMIT 1'); 457 $this->assertEquals($page, $edit['page']); 458 $this->assertEquals($type, $edit['type']); 459 } 460 461 /** 462 * Data provider for logLogin test 463 */ 464 public function logLoginProvider() 465 { 466 return [ 467 'login' => ['login', 'testuser'], 468 'logout' => ['logout', 'testuser'], 469 'create' => ['create', 'newuser'], 470 ]; 471 } 472 473 /** 474 * Test logLogin method 475 * @dataProvider logLoginProvider 476 */ 477 public function testLogLogin($type, $user) 478 { 479 $this->helper->getLogger()->logLogin($type, $user); 480 $login = $this->helper->getDB()->queryRecord('SELECT * FROM logins ORDER BY dt DESC LIMIT 1'); 481 $this->assertEquals($type, $login['type']); 482 $this->assertEquals($user, $login['user']); 483 } 484 485 /** 486 * Test logHistoryPages method 487 */ 488 public function testLogHistoryPages() 489 { 490 $this->helper->getLogger()->logHistoryPages(); 491 492 // Check that both page_count and page_size entries were created 493 $pageCount = $this->helper->getDB()->queryValue('SELECT value FROM history WHERE info = ?', ['page_count']); 494 $pageSize = $this->helper->getDB()->queryValue('SELECT value FROM history WHERE info = ?', ['page_size']); 495 496 $this->assertIsNumeric($pageCount); 497 $this->assertIsNumeric($pageSize); 498 $this->assertGreaterThanOrEqual(0, $pageCount); 499 $this->assertGreaterThanOrEqual(0, $pageSize); 500 } 501 502 /** 503 * Test logHistoryMedia method 504 */ 505 public function testLogHistoryMedia() 506 { 507 $this->helper->getLogger()->logHistoryMedia(); 508 509 // Check that both media_count and media_size entries were created 510 $mediaCount = $this->helper->getDB()->queryValue('SELECT value FROM history WHERE info = ?', ['media_count']); 511 $mediaSize = $this->helper->getDB()->queryValue('SELECT value FROM history WHERE info = ?', ['media_size']); 512 513 $this->assertIsNumeric($mediaCount); 514 $this->assertIsNumeric($mediaSize); 515 $this->assertGreaterThanOrEqual(0, $mediaCount); 516 $this->assertGreaterThanOrEqual(0, $mediaSize); 517 } 518 519 /** 520 * Test that feedreader user agents are handled correctly 521 */ 522 public function testFeedReaderUserAgent() 523 { 524 // Use a user agent that DeviceDetector recognizes as a feedreader 525 $_SERVER['HTTP_USER_AGENT'] = 'BashPodder/1.0 (http://bashpodder.sourceforge.net/)'; 526 527 $logger = new Logger($this->helper); 528 529 // Use reflection to access protected property 530 $reflection = new \ReflectionClass($logger); 531 $uaTypeProperty = $reflection->getProperty('uaType'); 532 $uaTypeProperty->setAccessible(true); 533 534 $this->assertEquals('feedreader', $uaTypeProperty->getValue($logger)); 535 } 536 537 /** 538 * Data provider for logCampaign test 539 */ 540 public function logCampaignProvider() 541 { 542 return [ 543 'all utm parameters' => [ 544 ['utm_campaign' => 'summer_sale', 'utm_source' => 'google', 'utm_medium' => 'cpc'], 545 ['summer_sale', 'google', 'cpc'], 546 true 547 ], 548 'only campaign' => [ 549 ['utm_campaign' => 'newsletter'], 550 ['newsletter', null, null], 551 true 552 ], 553 'only source' => [ 554 ['utm_source' => 'facebook'], 555 [null, 'facebook', null], 556 true 557 ], 558 'only medium' => [ 559 ['utm_medium' => 'email'], 560 [null, null, 'email'], 561 true 562 ], 563 'campaign and source' => [ 564 ['utm_campaign' => 'holiday', 'utm_source' => 'twitter'], 565 ['holiday', 'twitter', null], 566 true 567 ], 568 'no utm parameters' => [ 569 [], 570 [null, null, null], 571 false 572 ], 573 'empty utm parameters' => [ 574 ['utm_campaign' => '', 'utm_source' => '', 'utm_medium' => ''], 575 [null, null, null], 576 false 577 ], 578 'whitespace utm parameters' => [ 579 ['utm_campaign' => ' ', 'utm_source' => ' ', 'utm_medium' => ' '], 580 [null, null, null], 581 false 582 ], 583 'mixed empty and valid' => [ 584 ['utm_campaign' => '', 'utm_source' => 'instagram', 'utm_medium' => ''], 585 [null, 'instagram', null], 586 true 587 ], 588 ]; 589 } 590 591 /** 592 * Test logCampaign method 593 * @dataProvider logCampaignProvider 594 */ 595 public function testLogCampaign($inputParams, $expectedValues, $shouldBeLogged) 596 { 597 global $INPUT; 598 599 // Clean up any existing campaign data first 600 $this->helper->getDB()->exec('DELETE FROM campaigns WHERE session = ?', [self::SESSION_ID]); 601 602 // Set up INPUT parameters 603 foreach ($inputParams as $key => $value) { 604 $INPUT->set($key, $value); 605 } 606 607 $logger = $this->helper->getLogger(); 608 $logger->begin(); 609 $logger->end(); 610 611 if ($shouldBeLogged) { 612 $campaign = $this->helper->getDB()->queryRecord( 613 'SELECT * FROM campaigns WHERE session = ? ORDER BY rowid DESC LIMIT 1', 614 [self::SESSION_ID] 615 ); 616 617 $this->assertNotNull($campaign, 'Campaign should be logged'); 618 $this->assertEquals(self::SESSION_ID, $campaign['session']); 619 $this->assertEquals($expectedValues[0], $campaign['campaign']); 620 $this->assertEquals($expectedValues[1], $campaign['source']); 621 $this->assertEquals($expectedValues[2], $campaign['medium']); 622 } else { 623 $count = $this->helper->getDB()->queryValue( 624 'SELECT COUNT(*) FROM campaigns WHERE session = ?', 625 [self::SESSION_ID] 626 ); 627 $this->assertEquals(0, $count, 'No campaign should be logged'); 628 } 629 630 // Clean up INPUT for next test 631 foreach ($inputParams as $key => $value) { 632 $INPUT->set($key, null); 633 } 634 } 635 636 /** 637 * Test that logCampaign uses INSERT OR IGNORE to prevent duplicates 638 */ 639 public function testLogCampaignDuplicatePrevention() 640 { 641 global $INPUT; 642 643 // Clean up any existing campaign data first 644 $this->helper->getDB()->exec('DELETE FROM campaigns WHERE session = ?', [self::SESSION_ID]); 645 646 $INPUT->set('utm_campaign', 'test_campaign'); 647 $INPUT->set('utm_source', 'test_source'); 648 $INPUT->set('utm_medium', 'test_medium'); 649 650 // Log the same campaign twice 651 $logger1 = $this->helper->getLogger(); 652 $logger1->begin(); 653 $logger1->end(); 654 655 $logger2 = $this->helper->getLogger(); 656 $logger2->begin(); 657 $logger2->end(); 658 659 // Should only have one record due to INSERT OR IGNORE 660 $count = $this->helper->getDB()->queryValue( 661 'SELECT COUNT(*) FROM campaigns WHERE session = ?', 662 [self::SESSION_ID] 663 ); 664 $this->assertEquals(1, $count, 'Should only have one campaign record due to INSERT OR IGNORE'); 665 666 // Clean up 667 $INPUT->set('utm_campaign', null); 668 $INPUT->set('utm_source', null); 669 $INPUT->set('utm_medium', null); 670 } 671} 672