diff --git a/lib/ActiveConnections.php b/lib/ActiveConnections.php new file mode 100644 index 00000000..bd6f64fa --- /dev/null +++ b/lib/ActiveConnections.php @@ -0,0 +1,84 @@ + + * } + */ + public function getActiveConnections(): array { + try { + return [ + 'last5min' => $this->countSince(time() - 300), + 'last1h' => $this->countSince(time() - 3600), + 'totalTokens' => $this->countTotal(), + 'byType' => $this->byType(), + ]; + } catch (\Throwable) { + return ['last5min' => 0, 'last1h' => 0, 'totalTokens' => 0, 'byType' => []]; + } + } + + private function countSince(int $ts): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id')) + ->from('authtoken') + ->where($qb->expr()->gte('last_activity', $qb->createNamedParameter($ts))); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count; + } + + private function countTotal(): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id'))->from('authtoken'); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count; + } + + /** + * @return array + */ + private function byType(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('type') + ->selectAlias($qb->func()->count('id'), 'count') + ->from('authtoken') + ->where($qb->expr()->gte('last_activity', $qb->createNamedParameter(time() - 3600))) + ->groupBy('type'); + $result = $qb->executeQuery(); + $out = ['session' => 0, 'permanent' => 0]; + while (($row = $result->fetch()) !== false) { + $type = (int)($row['type'] ?? 0) === 0 ? 'session' : 'permanent'; + $out[$type] = (int)($row['count'] ?? 0); + } + $result->closeCursor(); + return $out; + } +} diff --git a/lib/ActivityRate.php b/lib/ActivityRate.php new file mode 100644 index 00000000..76656040 --- /dev/null +++ b/lib/ActivityRate.php @@ -0,0 +1,87 @@ + + * } + */ + public function getActivityRate(): array { + if (!$this->appManager->isInstalled('activity')) { + return ['installed' => false, 'last1h' => 0, 'last24h' => 0, 'last7d' => 0, 'topActions' => []]; + } + + try { + return [ + 'installed' => true, + 'last1h' => $this->countSince(time() - 3600), + 'last24h' => $this->countSince(time() - 86400), + 'last7d' => $this->countSince(time() - 7 * 86400), + 'topActions' => $this->topActions(), + ]; + } catch (\Throwable) { + return ['installed' => true, 'last1h' => 0, 'last24h' => 0, 'last7d' => 0, 'topActions' => []]; + } + } + + private function countSince(int $ts): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('activity_id')) + ->from('activity') + ->where($qb->expr()->gte('timestamp', $qb->createNamedParameter($ts))); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count; + } + + /** + * @return list + */ + private function topActions(int $limit = 5): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('subjectparams', 'type') + ->selectAlias($qb->func()->count('activity_id'), 'count') + ->from('activity') + ->where($qb->expr()->gte('timestamp', $qb->createNamedParameter(time() - 86400))) + ->groupBy('type', 'subjectparams') + ->orderBy('count', 'DESC') + ->setMaxResults($limit); + $result = $qb->executeQuery(); + $out = []; + while (($row = $result->fetch()) !== false) { + $out[] = [ + 'action' => (string)($row['type'] ?? 'unknown'), + 'count' => (int)($row['count'] ?? 0), + ]; + } + $result->closeCursor(); + return $out; + } +} diff --git a/lib/LogTailReader.php b/lib/LogTailReader.php new file mode 100644 index 00000000..c3fba5f5 --- /dev/null +++ b/lib/LogTailReader.php @@ -0,0 +1,69 @@ +, + * available: bool, + * reason?: string + * } + */ + public function recentErrors(int $limit = 8, int $minLevel = 2): array { + $logType = $this->config->getSystemValue('log_type', 'file'); + if ($logType !== 'file') { + return ['entries' => [], 'available' => false, 'reason' => 'log_type_not_file']; + } + + $log = $this->logFactory->get('file'); + if (!($log instanceof IFileBased)) { + return ['entries' => [], 'available' => false, 'reason' => 'log_not_readable']; + } + + $raw = $log->getEntries($limit * 10); + $collected = []; + foreach ($raw as $entry) { + if (count($collected) >= $limit) { + break; + } + $level = (int)($entry['level'] ?? 0); + if ($level < $minLevel) { + continue; + } + $collected[] = [ + 'time' => (string)($entry['time'] ?? ''), + 'level' => $level, + 'app' => (string)($entry['app'] ?? ''), + 'message' => $this->snippet((string)($entry['message'] ?? '')), + ]; + } + + return ['entries' => $collected, 'available' => true]; + } + + private function snippet(string $msg, int $max = 200): string { + $msg = trim($msg); + if (mb_strlen($msg) > $max) { + return mb_substr($msg, 0, $max - 1) . '…'; + } + return $msg; + } +} diff --git a/lib/LoginStats.php b/lib/LoginStats.php new file mode 100644 index 00000000..4dd84b52 --- /dev/null +++ b/lib/LoginStats.php @@ -0,0 +1,112 @@ +, + * available: bool, + * reason?: string + * } + */ + public function getStats(): array { + if ($this->usesRedisBruteforceBackend()) { + return [ + 'bruteforceAttempts24h' => 0, + 'bruteforceAttempts1h' => 0, + 'bruteforceTotal' => 0, + 'topIps' => [], + 'available' => false, + 'reason' => 'redis_backend', + ]; + } + + try { + $total = $this->countAttempts(); + } catch (\Throwable) { + return [ + 'bruteforceAttempts24h' => 0, + 'bruteforceAttempts1h' => 0, + 'bruteforceTotal' => 0, + 'topIps' => [], + 'available' => false, + ]; + } + + return [ + 'bruteforceAttempts24h' => $this->countAttempts(time() - 86400), + 'bruteforceAttempts1h' => $this->countAttempts(time() - 3600), + 'bruteforceTotal' => $total, + 'topIps' => $this->topIps(), + 'available' => true, + ]; + } + + private function usesRedisBruteforceBackend(): bool { + if ($this->config->getSystemValueBool('auth.bruteforce.protection.force.database', false)) { + return false; + } + $distributed = ltrim($this->config->getSystemValueString('memcache.distributed', ''), '\\'); + return $distributed === 'OC\Memcache\Redis'; + } + + private function countAttempts(?int $sinceTimestamp = null): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('id'))->from('bruteforce_attempts'); + if ($sinceTimestamp !== null) { + $qb->where($qb->expr()->gte('occurred', $qb->createNamedParameter($sinceTimestamp))); + } + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count; + } + + /** + * @return list + */ + private function topIps(int $limit = 5): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('ip') + ->selectAlias($qb->func()->count('id'), 'count') + ->from('bruteforce_attempts') + ->where($qb->expr()->gte('occurred', $qb->createNamedParameter(time() - 86400))) + ->groupBy('ip') + ->orderBy('count', 'DESC') + ->setMaxResults($limit); + try { + $result = $qb->executeQuery(); + } catch (\Throwable) { + return []; + } + $out = []; + while (($row = $result->fetch()) !== false) { + $out[] = [ + 'ip' => (string)($row['ip'] ?? ''), + 'count' => (int)($row['count'] ?? 0), + ]; + } + $result->closeCursor(); + return $out; + } +} diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index b5360171..4ddf253b 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -10,10 +10,14 @@ namespace OCA\ServerInfo\Settings; +use OCA\ServerInfo\ActiveConnections; +use OCA\ServerInfo\ActivityRate; use OCA\ServerInfo\CronInfo; use OCA\ServerInfo\DatabaseStatistics; use OCA\ServerInfo\FpmStatistics; use OCA\ServerInfo\JobQueueInfo; +use OCA\ServerInfo\LoginStats; +use OCA\ServerInfo\LogTailReader; use OCA\ServerInfo\Os; use OCA\ServerInfo\PhpStatistics; use OCA\ServerInfo\SessionStatistics; @@ -42,6 +46,10 @@ public function __construct( private CronInfo $cronInfo, private JobQueueInfo $jobQueueInfo, private SlowestJobs $slowestJobs, + private LogTailReader $logTailReader, + private LoginStats $loginStats, + private ActivityRate $activityRate, + private ActiveConnections $activeConnections, private IConfig $config, ) { } @@ -69,6 +77,10 @@ public function getForm(): TemplateResponse { 'cron' => $this->cronInfo->getCronInfo(), 'jobQueue' => $this->jobQueueInfo->getJobQueueInfo(), 'slowestJobs' => $this->slowestJobs->getSlowestJobs(), + 'logTail' => $this->logTailReader->recentErrors(), + 'loginStats' => $this->loginStats->getStats(), + 'activityRate' => $this->activityRate->getActivityRate(), + 'activeConnections' => $this->activeConnections->getActiveConnections(), 'phpinfo' => $this->config->getAppValue('serverinfo', 'phpinfo', 'no') === 'yes', 'phpinfoUrl' => $this->urlGenerator->linkToRoute('serverinfo.page.phpinfo') ]; diff --git a/tests/lib/ActiveConnectionsTest.php b/tests/lib/ActiveConnectionsTest.php new file mode 100644 index 00000000..54f3b888 --- /dev/null +++ b/tests/lib/ActiveConnectionsTest.php @@ -0,0 +1,170 @@ +db = Server::get(IDBConnection::class); + $this->instance = new ActiveConnections($this->db); + } + + protected function tearDown(): void { + $this->cleanUp(); + parent::tearDown(); + } + + private function cleanUp(): void { + if ($this->insertedTokens === []) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete('authtoken') + ->where($qb->expr()->in('token', $qb->createNamedParameter($this->insertedTokens, IQueryBuilder::PARAM_STR_ARRAY))); + $qb->executeStatement(); + $this->insertedTokens = []; + } + + private function insertToken(int $lastActivity, int $type = 0): void { + static $uid = 0; + $uid++; + $token = bin2hex(random_bytes(32)); + $this->insertedTokens[] = $token; + $qb = $this->db->getQueryBuilder(); + $qb->insert('authtoken') + ->values([ + 'uid' => $qb->createNamedParameter('testuser' . $uid), + 'login_name' => $qb->createNamedParameter('testuser' . $uid), + 'password' => $qb->createNamedParameter(''), + 'name' => $qb->createNamedParameter('Test token ' . $uid), + 'token' => $qb->createNamedParameter($token), + 'type' => $qb->createNamedParameter($type), + 'last_activity' => $qb->createNamedParameter($lastActivity), + 'last_check' => $qb->createNamedParameter(time()), + ]); + $qb->executeStatement(); + } + + public function testReturnShape(): void { + $result = $this->instance->getActiveConnections(); + + $this->assertArrayHasKey('last5min', $result); + $this->assertArrayHasKey('last1h', $result); + $this->assertArrayHasKey('totalTokens', $result); + $this->assertArrayHasKey('byType', $result); + $this->assertIsInt($result['last5min']); + $this->assertIsInt($result['last1h']); + $this->assertIsInt($result['totalTokens']); + $this->assertIsArray($result['byType']); + } + + public function testLast5minCountIncreases(): void { + $baseline = $this->instance->getActiveConnections()['last5min']; + + $this->insertToken(time() - 60); + + $result = $this->instance->getActiveConnections(); + + $this->assertSame($baseline + 1, $result['last5min']); + } + + public function testLast5minExcludesOldTokens(): void { + $baseline = $this->instance->getActiveConnections()['last5min']; + + $this->insertToken(time() - 600); + + $result = $this->instance->getActiveConnections(); + + $this->assertSame($baseline, $result['last5min']); + } + + public function testLast1hCountIncreases(): void { + $baseline = $this->instance->getActiveConnections()['last1h']; + + $this->insertToken(time() - 1800); + + $result = $this->instance->getActiveConnections(); + + $this->assertSame($baseline + 1, $result['last1h']); + } + + public function testLast1hExcludesOldTokens(): void { + $baseline = $this->instance->getActiveConnections()['last1h']; + + $this->insertToken(time() - 7200); + + $result = $this->instance->getActiveConnections(); + + $this->assertSame($baseline, $result['last1h']); + } + + public function testTotalTokensCountIncreases(): void { + $baseline = $this->instance->getActiveConnections()['totalTokens']; + + $this->insertToken(time() - 99999); + + $result = $this->instance->getActiveConnections(); + + $this->assertSame($baseline + 1, $result['totalTokens']); + } + + public function testByTypeContainsSessionAndPermanent(): void { + $result = $this->instance->getActiveConnections(); + + $this->assertArrayHasKey('session', $result['byType']); + $this->assertArrayHasKey('permanent', $result['byType']); + } + + public function testByTypeCountsSessionTokens(): void { + $baseline = $this->instance->getActiveConnections()['byType']['session'] ?? 0; + + $this->insertToken(time() - 60, type: 0); + + $result = $this->instance->getActiveConnections(); + + $this->assertSame($baseline + 1, $result['byType']['session']); + } + + public function testByTypeCountsPermanentTokens(): void { + $baseline = $this->instance->getActiveConnections()['byType']['permanent'] ?? 0; + + $this->insertToken(time() - 60, type: 1); + + $result = $this->instance->getActiveConnections(); + + $this->assertSame($baseline + 1, $result['byType']['permanent']); + } + + public function testLast5minIsSubsetOfLast1h(): void { + $result = $this->instance->getActiveConnections(); + + $this->assertLessThanOrEqual($result['last1h'], $result['last5min']); + } + + public function testLast1hIsSubsetOfTotalTokens(): void { + $result = $this->instance->getActiveConnections(); + + $this->assertLessThanOrEqual($result['totalTokens'], $result['last1h']); + } +} diff --git a/tests/lib/ActivityRateTest.php b/tests/lib/ActivityRateTest.php new file mode 100644 index 00000000..70df693c --- /dev/null +++ b/tests/lib/ActivityRateTest.php @@ -0,0 +1,211 @@ +db = Server::get(IDBConnection::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->instance = new ActivityRate($this->appManager, $this->db); + $this->tableAvailable = $this->checkTableAvailable(); + } + + protected function tearDown(): void { + if ($this->tableAvailable) { + $qb = $this->db->getQueryBuilder(); + $qb->delete('activity') + ->where($qb->expr()->eq('app', $qb->createNamedParameter('serverinfo_test'))); + $qb->executeStatement(); + } + parent::tearDown(); + } + + private function checkTableAvailable(): bool { + try { + $this->db->getQueryBuilder()->select('activity_id')->from('activity')->setMaxResults(1)->executeQuery()->closeCursor(); + return true; + } catch (\Throwable) { + return false; + } + } + + private function insertActivity(string $type, int $timestamp): void { + $qb = $this->db->getQueryBuilder(); + $qb->insert('activity') + ->values([ + 'timestamp' => $qb->createNamedParameter($timestamp), + 'priority' => $qb->createNamedParameter(30), + 'type' => $qb->createNamedParameter($type), + 'user' => $qb->createNamedParameter('testuser'), + 'affecteduser' => $qb->createNamedParameter('testuser'), + 'app' => $qb->createNamedParameter('serverinfo_test'), + 'subject' => $qb->createNamedParameter('test_subject'), + 'subjectparams' => $qb->createNamedParameter('[]'), + 'message' => $qb->createNamedParameter(''), + 'messageparams' => $qb->createNamedParameter('[]'), + 'file' => $qb->createNamedParameter(''), + 'link' => $qb->createNamedParameter(''), + 'object_type' => $qb->createNamedParameter(''), + 'object_id' => $qb->createNamedParameter(0), + ]); + $qb->executeStatement(); + } + + public function testNotInstalledReturnsInstalledFalse(): void { + $this->appManager->method('isInstalled')->with('activity')->willReturn(false); + + $result = $this->instance->getActivityRate(); + + $this->assertFalse($result['installed']); + $this->assertSame(0, $result['last1h']); + $this->assertSame(0, $result['last24h']); + $this->assertSame(0, $result['last7d']); + $this->assertSame([], $result['topActions']); + } + + public function testReturnShape(): void { + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $result = $this->instance->getActivityRate(); + + $this->assertArrayHasKey('installed', $result); + $this->assertArrayHasKey('last1h', $result); + $this->assertArrayHasKey('last24h', $result); + $this->assertArrayHasKey('last7d', $result); + $this->assertArrayHasKey('topActions', $result); + $this->assertTrue($result['installed']); + $this->assertIsInt($result['last1h']); + $this->assertIsInt($result['last24h']); + $this->assertIsInt($result['last7d']); + $this->assertIsArray($result['topActions']); + } + + public function testCountsAreNonNegative(): void { + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $result = $this->instance->getActivityRate(); + + $this->assertGreaterThanOrEqual(0, $result['last1h']); + $this->assertGreaterThanOrEqual(0, $result['last24h']); + $this->assertGreaterThanOrEqual(0, $result['last7d']); + } + + public function testHierarchyLast1hLeqlast24hLeqlast7d(): void { + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $result = $this->instance->getActivityRate(); + + $this->assertLessThanOrEqual($result['last24h'], $result['last1h']); + $this->assertLessThanOrEqual($result['last7d'], $result['last24h']); + } + + public function testTopActionsShape(): void { + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $result = $this->instance->getActivityRate(); + + $this->assertIsArray($result['topActions']); + foreach ($result['topActions'] as $entry) { + $this->assertArrayHasKey('action', $entry); + $this->assertArrayHasKey('count', $entry); + $this->assertIsString($entry['action']); + $this->assertIsInt($entry['count']); + } + } + + public function testLast1hCountIncreasesWithRecentActivity(): void { + if (!$this->tableAvailable) { + $this->markTestSkipped('activity table not available'); + } + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $baseline = $this->instance->getActivityRate()['last1h']; + $this->insertActivity('file_created', time() - 60); + + $result = $this->instance->getActivityRate(); + + $this->assertSame($baseline + 1, $result['last1h']); + } + + public function testLast1hExcludesOldActivity(): void { + if (!$this->tableAvailable) { + $this->markTestSkipped('activity table not available'); + } + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $baseline = $this->instance->getActivityRate()['last1h']; + $this->insertActivity('file_created', time() - 7200); + + $result = $this->instance->getActivityRate(); + + $this->assertSame($baseline, $result['last1h']); + } + + public function testLast24hCountIncreasesWithRecentActivity(): void { + if (!$this->tableAvailable) { + $this->markTestSkipped('activity table not available'); + } + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $baseline = $this->instance->getActivityRate()['last24h']; + $this->insertActivity('file_created', time() - 3600); + + $result = $this->instance->getActivityRate(); + + $this->assertSame($baseline + 1, $result['last24h']); + } + + public function testLast7dCountIncreasesWithActivity(): void { + if (!$this->tableAvailable) { + $this->markTestSkipped('activity table not available'); + } + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $baseline = $this->instance->getActivityRate()['last7d']; + $this->insertActivity('file_created', time() - (3 * 86400)); + + $result = $this->instance->getActivityRate(); + + $this->assertSame($baseline + 1, $result['last7d']); + } + + public function testTopActionsReturnsInsertedTypes(): void { + if (!$this->tableAvailable) { + $this->markTestSkipped('activity table not available'); + } + $this->appManager->method('isInstalled')->with('activity')->willReturn(true); + + $this->insertActivity('serverinfo_test_action', time() - 60); + $this->insertActivity('serverinfo_test_action', time() - 120); + + $result = $this->instance->getActivityRate(); + + $actions = array_column($result['topActions'], 'action'); + $this->assertContains('serverinfo_test_action', $actions); + $entry = current(array_filter($result['topActions'], fn ($r) => $r['action'] === 'serverinfo_test_action')); + $this->assertNotFalse($entry); + $this->assertGreaterThanOrEqual(2, $entry['count']); + } +} diff --git a/tests/lib/LogTailReaderTest.php b/tests/lib/LogTailReaderTest.php new file mode 100644 index 00000000..064a3b6d --- /dev/null +++ b/tests/lib/LogTailReaderTest.php @@ -0,0 +1,144 @@ +config = $this->createMock(IConfig::class); + $this->logFactory = $this->createMock(ILogFactory::class); + $this->instance = new LogTailReader($this->config, $this->logFactory); + } + + /** @param list> $entries */ + private function setupFileLog(array $entries = []): void { + $this->config->method('getSystemValue')->with('log_type', 'file')->willReturn('file'); + $log = $this->createMockForIntersectionOfInterfaces([IWriter::class, IFileBased::class]); + $log->method('getEntries')->willReturn($entries); + $this->logFactory->method('get')->with('file')->willReturn($log); + } + + public function testNonFileLogTypeReturnsUnavailable(): void { + $this->config->method('getSystemValue')->with('log_type', 'file')->willReturn('syslog'); + + $result = $this->instance->recentErrors(); + + $this->assertFalse($result['available']); + $this->assertSame('log_type_not_file', $result['reason']); + $this->assertSame([], $result['entries']); + } + + public function testLogNotFileBasedReturnsUnavailable(): void { + $this->config->method('getSystemValue')->with('log_type', 'file')->willReturn('file'); + $writer = $this->createMock(IWriter::class); + $this->logFactory->method('get')->with('file')->willReturn($writer); + + $result = $this->instance->recentErrors(); + + $this->assertFalse($result['available']); + $this->assertSame('log_not_readable', $result['reason']); + } + + public function testEmptyEntriesReturnsAvailableWithNoEntries(): void { + $this->setupFileLog([]); + + $result = $this->instance->recentErrors(); + + $this->assertTrue($result['available']); + $this->assertSame([], $result['entries']); + } + + public function testReturnShape(): void { + $this->setupFileLog([ + ['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'something failed'], + ]); + + $result = $this->instance->recentErrors(); + + $this->assertArrayHasKey('entries', $result); + $this->assertArrayHasKey('available', $result); + $this->assertTrue($result['available']); + $this->assertCount(1, $result['entries']); + $entry = $result['entries'][0]; + $this->assertArrayHasKey('time', $entry); + $this->assertArrayHasKey('level', $entry); + $this->assertArrayHasKey('app', $entry); + $this->assertArrayHasKey('message', $entry); + $this->assertSame(3, $entry['level']); + $this->assertSame('core', $entry['app']); + } + + public function testEntriesBelowMinLevelAreFiltered(): void { + $this->setupFileLog([ + ['time' => '2026-01-01T00:00:03+00:00', 'level' => 3, 'app' => 'a', 'message' => 'error'], + ['time' => '2026-01-01T00:00:02+00:00', 'level' => 2, 'app' => 'a', 'message' => 'warn'], + ['time' => '2026-01-01T00:00:01+00:00', 'level' => 1, 'app' => 'a', 'message' => 'info'], + ['time' => '2026-01-01T00:00:00+00:00', 'level' => 0, 'app' => 'a', 'message' => 'debug'], + ]); + + $result = $this->instance->recentErrors(limit: 10, minLevel: 2); + + $this->assertTrue($result['available']); + $this->assertCount(2, $result['entries']); + foreach ($result['entries'] as $entry) { + $this->assertGreaterThanOrEqual(2, $entry['level']); + } + } + + public function testLimitIsRespected(): void { + $entries = []; + for ($i = 0; $i < 10; $i++) { + $entries[] = ['time' => "2026-01-01T00:00:{$i}0+00:00", 'level' => 3, 'app' => 'test', 'message' => "error $i"]; + } + $this->setupFileLog($entries); + + $result = $this->instance->recentErrors(limit: 3); + + $this->assertTrue($result['available']); + $this->assertCount(3, $result['entries']); + } + + public function testLongMessageIsTruncated(): void { + $this->setupFileLog([ + ['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => str_repeat('a', 300)], + ]); + + $result = $this->instance->recentErrors(); + + $this->assertCount(1, $result['entries']); + $this->assertLessThanOrEqual(200, mb_strlen($result['entries'][0]['message'])); + } + + public function testOrderFromGetEntriesIsPreserved(): void { + $this->setupFileLog([ + ['time' => '2026-01-01T00:00:02+00:00', 'level' => 3, 'app' => 'a', 'message' => 'third'], + ['time' => '2026-01-01T00:00:01+00:00', 'level' => 3, 'app' => 'a', 'message' => 'second'], + ['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'a', 'message' => 'first'], + ]); + + $result = $this->instance->recentErrors(); + + $this->assertCount(3, $result['entries']); + $this->assertSame('third', $result['entries'][0]['message']); + $this->assertSame('first', $result['entries'][2]['message']); + } +} diff --git a/tests/lib/LoginStatsTest.php b/tests/lib/LoginStatsTest.php new file mode 100644 index 00000000..be5cb56c --- /dev/null +++ b/tests/lib/LoginStatsTest.php @@ -0,0 +1,190 @@ +db = Server::get(IDBConnection::class); + $this->config = $this->createMock(IConfig::class); + $this->config->method('getSystemValueBool')->willReturn(false); + $this->config->method('getSystemValueString')->willReturn(''); + $this->instance = new LoginStats($this->config, $this->db); + $this->cleanUp(); + } + + protected function tearDown(): void { + $this->cleanUp(); + parent::tearDown(); + } + + private function cleanUp(): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('bruteforce_attempts') + ->where($qb->expr()->in('ip', $qb->createNamedParameter( + [self::IP_A, self::IP_B, self::IP_C], + IQueryBuilder::PARAM_STR_ARRAY + ))); + $qb->executeStatement(); + } + + private function insertAttempt(string $ip, int $occurred): void { + $qb = $this->db->getQueryBuilder(); + $qb->insert('bruteforce_attempts') + ->values([ + 'action' => $qb->createNamedParameter('login'), + 'occurred' => $qb->createNamedParameter($occurred), + 'ip' => $qb->createNamedParameter($ip), + 'subnet' => $qb->createNamedParameter($ip . '/32'), + 'metadata' => $qb->createNamedParameter('{}'), + ]); + $qb->executeStatement(); + } + + public function testRedisBackendReturnsUnavailable(): void { + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueBool')->willReturn(false); + $config->method('getSystemValueString')->willReturn('OC\Memcache\Redis'); + $instance = new LoginStats($config, $this->db); + + $result = $instance->getStats(); + + $this->assertFalse($result['available']); + $this->assertSame('redis_backend', $result['reason']); + } + + public function testForceDatabaseOverridesRedis(): void { + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueBool')->willReturn(true); + $config->method('getSystemValueString')->willReturn('OC\Memcache\Redis'); + $instance = new LoginStats($config, $this->db); + + $result = $instance->getStats(); + + $this->assertTrue($result['available']); + } + + public function testReturnShape(): void { + $result = $this->instance->getStats(); + + $this->assertArrayHasKey('bruteforceAttempts24h', $result); + $this->assertArrayHasKey('bruteforceAttempts1h', $result); + $this->assertArrayHasKey('bruteforceTotal', $result); + $this->assertArrayHasKey('topIps', $result); + $this->assertArrayHasKey('available', $result); + $this->assertTrue($result['available']); + $this->assertIsInt($result['bruteforceAttempts24h']); + $this->assertIsInt($result['bruteforceAttempts1h']); + $this->assertIsInt($result['bruteforceTotal']); + $this->assertIsArray($result['topIps']); + } + + public function testTotalCountIncreases(): void { + $baseline = $this->instance->getStats()['bruteforceTotal']; + + $this->insertAttempt(self::IP_A, time() - 7200); + $this->insertAttempt(self::IP_B, time() - 3000); + + $result = $this->instance->getStats(); + + $this->assertSame($baseline + 2, $result['bruteforceTotal']); + } + + public function test24hCountFiltersOldAttempts(): void { + $baseline = $this->instance->getStats()['bruteforceAttempts24h']; + + $this->insertAttempt(self::IP_A, time() - 100); + $this->insertAttempt(self::IP_B, time() - (25 * 3600)); + + $result = $this->instance->getStats(); + + $this->assertSame($baseline + 1, $result['bruteforceAttempts24h']); + } + + public function test1hCountFiltersOlderAttempts(): void { + $baseline = $this->instance->getStats()['bruteforceAttempts1h']; + + $this->insertAttempt(self::IP_A, time() - 60); + $this->insertAttempt(self::IP_B, time() - 7200); + + $result = $this->instance->getStats(); + + $this->assertSame($baseline + 1, $result['bruteforceAttempts1h']); + } + + public function testTopIpsShape(): void { + $this->insertAttempt(self::IP_A, time() - 60); + + $result = $this->instance->getStats(); + + $this->assertIsArray($result['topIps']); + foreach ($result['topIps'] as $entry) { + $this->assertArrayHasKey('ip', $entry); + $this->assertArrayHasKey('count', $entry); + $this->assertIsString($entry['ip']); + $this->assertIsInt($entry['count']); + } + } + + public function testTopIpsOrderedByCountDescending(): void { + $now = time(); + $this->insertAttempt(self::IP_A, $now - 60); + $this->insertAttempt(self::IP_B, $now - 120); + $this->insertAttempt(self::IP_B, $now - 180); + $this->insertAttempt(self::IP_B, $now - 240); + + $result = $this->instance->getStats(); + + $topIps = $result['topIps']; + $this->assertNotEmpty($topIps); + $ipAddresses = array_column($topIps, 'ip'); + $posA = array_search(self::IP_A, $ipAddresses); + $posB = array_search(self::IP_B, $ipAddresses); + $this->assertNotFalse($posA); + $this->assertNotFalse($posB); + $this->assertLessThan($posA, $posB); + } + + public function testTopIpsLimitedToFive(): void { + $ips = ['192.168.1.1', '192.168.1.2', '192.168.1.3', '192.168.1.4', '192.168.1.5', '192.168.1.6']; + $now = time(); + foreach ($ips as $ip) { + $this->insertAttempt($ip, $now - 60); + } + + $result = $this->instance->getStats(); + + $this->assertLessThanOrEqual(5, count($result['topIps'])); + + $qb = $this->db->getQueryBuilder(); + $qb->delete('bruteforce_attempts') + ->where($qb->expr()->in('ip', $qb->createNamedParameter($ips, IQueryBuilder::PARAM_STR_ARRAY))); + $qb->executeStatement(); + } +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 0616cb74..1774d5c6 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,5 +1,15 @@ + + + + + + + + + + @@ -40,6 +50,16 @@ + + + + + + + + + +