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