From 5fbd0c41ea61aa87055172dabd87443708c4be86 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Tue, 28 Apr 2026 13:58:20 +0200 Subject: [PATCH 1/4] feat(services): add log and login/session services Adds four read-only service classes used by the upcoming admin dashboard cards. None are wired into existing controllers yet, so this change is a pure addition with no behavior change. * LogTailReader - last N WARN/ERROR entries from the JSON log (no shell, line-bounded) * LoginStats - bruteforce attempt counters and the top offending IPs over recent windows * ActivityRate - oc_activity row counts over the last hour / day / week * ActiveConnections - session counts derived from oc_authtoken Signed-off-by: Frank Karlitschek --- lib/ActiveConnections.php | 84 ++++++++++++++++++++++++++ lib/ActivityRate.php | 87 +++++++++++++++++++++++++++ lib/LogTailReader.php | 121 ++++++++++++++++++++++++++++++++++++++ lib/LoginStats.php | 90 ++++++++++++++++++++++++++++ 4 files changed, 382 insertions(+) create mode 100644 lib/ActiveConnections.php create mode 100644 lib/ActivityRate.php create mode 100644 lib/LogTailReader.php create mode 100644 lib/LoginStats.php 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..0ad0ad16 --- /dev/null +++ b/lib/LogTailReader.php @@ -0,0 +1,121 @@ += $minLevel (default: WARN = 2). Skips DEBUG/INFO. + * + * @return array{ + * entries: list, + * 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']; + } + + $path = $this->resolvePath(); + if ($path === null || !is_readable($path)) { + return ['entries' => [], 'available' => false, 'reason' => 'log_not_readable']; + } + + $tail = $this->tailFile($path, self::READ_CHUNK); + if ($tail === '') { + return ['entries' => [], 'available' => true]; + } + + $lines = explode("\n", $tail); + $collected = []; + // Iterate from newest to oldest. + for ($i = count($lines) - 1; $i >= 0 && count($collected) < $limit; $i--) { + $line = trim($lines[$i]); + if ($line === '') { + continue; + } + $decoded = json_decode($line, true); + if (!is_array($decoded)) { + continue; + } + $level = isset($decoded['level']) ? (int)$decoded['level'] : 0; + if ($level < $minLevel) { + continue; + } + $collected[] = [ + 'time' => (string)($decoded['time'] ?? ''), + 'level' => $level, + 'app' => (string)($decoded['app'] ?? ''), + 'message' => $this->snippet((string)($decoded['message'] ?? '')), + ]; + } + + return ['entries' => $collected, 'available' => true]; + } + + private function resolvePath(): ?string { + $dataDir = $this->config->getSystemValue('datadirectory', ''); + $default = $dataDir !== '' ? rtrim($dataDir, '/') . '/nextcloud.log' : ''; + $logFile = $this->config->getSystemValue('logfile', $default); + if (!is_string($logFile) || $logFile === '') { + return null; + } + return $logFile; + } + + private function tailFile(string $path, int $chunk): string { + $size = @filesize($path); + if ($size === false || $size === 0) { + return ''; + } + $handle = @fopen($path, 'rb'); + if ($handle === false) { + return ''; + } + try { + $readFrom = max(0, $size - $chunk); + fseek($handle, $readFrom); + $data = fread($handle, $chunk) ?: ''; + // Drop the leading partial line so JSON parsing doesn't choke. + if ($readFrom > 0) { + $nl = strpos($data, "\n"); + if ($nl !== false) { + $data = substr($data, $nl + 1); + } + } + return $data; + } finally { + fclose($handle); + } + } + + private function snippet(string $msg, int $max = 200): string { + $msg = trim($msg); + if (function_exists('mb_strlen') && mb_strlen($msg) > $max) { + return mb_substr($msg, 0, $max - 1) . '…'; + } + if (strlen($msg) > $max) { + return substr($msg, 0, $max - 1) . '…'; + } + return $msg; + } +} diff --git a/lib/LoginStats.php b/lib/LoginStats.php new file mode 100644 index 00000000..9a089a50 --- /dev/null +++ b/lib/LoginStats.php @@ -0,0 +1,90 @@ +, + * available: bool + * } + */ + public function getStats(): array { + 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 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; + } +} From b48f36c507f9c05a938417f8eae75088095136fc Mon Sep 17 00:00:00 2001 From: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> Date: Mon, 11 May 2026 18:18:50 +0200 Subject: [PATCH 2/4] feat(settings): wire log and login/session services into admin settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Injects LogTailReader, LoginStats, ActivityRate, and ActiveConnections — added in #982 — into AdminSettings and passes their data to the template params, making the services active instead of dead code. Extracted from #977. Co-Authored-By: Frank Karlitschek AI-assisted: Claude Code (claude-sonnet-4-6) Signed-off-by: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> --- lib/Settings/AdminSettings.php | 12 ++ tests/lib/ActiveConnectionsTest.php | 170 ++++++++++++++++++++++ tests/lib/ActivityRateTest.php | 211 ++++++++++++++++++++++++++++ tests/lib/LogTailReaderTest.php | 203 ++++++++++++++++++++++++++ tests/lib/LoginStatsTest.php | 160 +++++++++++++++++++++ tests/psalm-baseline.xml | 20 +++ 6 files changed, 776 insertions(+) create mode 100644 tests/lib/ActiveConnectionsTest.php create mode 100644 tests/lib/ActivityRateTest.php create mode 100644 tests/lib/LogTailReaderTest.php create mode 100644 tests/lib/LoginStatsTest.php 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..bb9beccb --- /dev/null +++ b/tests/lib/LogTailReaderTest.php @@ -0,0 +1,203 @@ +config = $this->createMock(IConfig::class); + $this->instance = new LogTailReader($this->config); + $this->tmpDir = sys_get_temp_dir(); + } + + private function configReturns(string $logType, string $logFile = ''): void { + $this->config->method('getSystemValue') + ->willReturnCallback(function (string $key) use ($logType, $logFile): string { + return match ($key) { + 'log_type' => $logType, + 'datadirectory' => '', + 'logfile' => $logFile, + default => '', + }; + }); + } + + private function setupFileLog(string $path): void { + $this->configReturns('file', $path); + } + + public function testNonFileLogTypeReturnsUnavailable(): void { + $this->configReturns('syslog'); + + $result = $this->instance->recentErrors(); + + $this->assertFalse($result['available']); + $this->assertSame('log_type_not_file', $result['reason']); + $this->assertSame([], $result['entries']); + } + + public function testUnreadablePathReturnsUnavailable(): void { + $this->configReturns('file', '/nonexistent/path/nextcloud.log'); + + $result = $this->instance->recentErrors(); + + $this->assertFalse($result['available']); + $this->assertSame('log_not_readable', $result['reason']); + } + + public function testEmptyLogFileReturnsAvailableWithNoEntries(): void { + $path = tempnam($this->tmpDir, 'nc_log_test_'); + file_put_contents($path, ''); + $this->setupFileLog($path); + + try { + $result = $this->instance->recentErrors(); + + $this->assertTrue($result['available']); + $this->assertSame([], $result['entries']); + } finally { + unlink($path); + } + } + + public function testEntriesBelowMinLevelAreFiltered(): void { + $path = tempnam($this->tmpDir, 'nc_log_test_'); + $lines = [ + json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 0, 'app' => 'a', 'message' => 'debug']), + json_encode(['time' => '2026-01-01T00:00:01+00:00', 'level' => 1, 'app' => 'a', 'message' => 'info']), + json_encode(['time' => '2026-01-01T00:00:02+00:00', 'level' => 2, 'app' => 'a', 'message' => 'warn']), + json_encode(['time' => '2026-01-01T00:00:03+00:00', 'level' => 3, 'app' => 'a', 'message' => 'error']), + ]; + file_put_contents($path, implode("\n", $lines) . "\n"); + $this->setupFileLog($path); + + try { + $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']); + } + } finally { + unlink($path); + } + } + + public function testLimitIsRespected(): void { + $path = tempnam($this->tmpDir, 'nc_log_test_'); + $lines = []; + for ($i = 0; $i < 10; $i++) { + $lines[] = json_encode(['time' => "2026-01-01T00:00:{$i}0+00:00", 'level' => 3, 'app' => 'test', 'message' => "error $i"]); + } + file_put_contents($path, implode("\n", $lines) . "\n"); + $this->setupFileLog($path); + + try { + $result = $this->instance->recentErrors(limit: 3); + + $this->assertTrue($result['available']); + $this->assertCount(3, $result['entries']); + } finally { + unlink($path); + } + } + + public function testReturnShape(): void { + $path = tempnam($this->tmpDir, 'nc_log_test_'); + $line = json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'something failed']); + file_put_contents($path, $line . "\n"); + $this->setupFileLog($path); + + try { + $result = $this->instance->recentErrors(); + + $this->assertArrayHasKey('entries', $result); + $this->assertArrayHasKey('available', $result); + $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']); + } finally { + unlink($path); + } + } + + public function testLongMessageIsTruncated(): void { + $path = tempnam($this->tmpDir, 'nc_log_test_'); + $longMsg = str_repeat('a', 300); + $line = json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => $longMsg]); + file_put_contents($path, $line . "\n"); + $this->setupFileLog($path); + + try { + $result = $this->instance->recentErrors(); + + $this->assertCount(1, $result['entries']); + // snippet() uses mb_strlen/mb_substr so measure in characters, not bytes + $this->assertLessThanOrEqual(200, mb_strlen($result['entries'][0]['message'])); + } finally { + unlink($path); + } + } + + public function testInvalidJsonLinesAreSkipped(): void { + $path = tempnam($this->tmpDir, 'nc_log_test_'); + $lines = [ + 'not valid json', + json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'real error']), + '{broken', + ]; + file_put_contents($path, implode("\n", $lines) . "\n"); + $this->setupFileLog($path); + + try { + $result = $this->instance->recentErrors(); + + $this->assertCount(1, $result['entries']); + } finally { + unlink($path); + } + } + + public function testEntriesReturnedNewestFirst(): void { + $path = tempnam($this->tmpDir, 'nc_log_test_'); + $lines = [ + json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'a', 'message' => 'first']), + json_encode(['time' => '2026-01-01T00:00:01+00:00', 'level' => 3, 'app' => 'a', 'message' => 'second']), + json_encode(['time' => '2026-01-01T00:00:02+00:00', 'level' => 3, 'app' => 'a', 'message' => 'third']), + ]; + file_put_contents($path, implode("\n", $lines) . "\n"); + $this->setupFileLog($path); + + try { + $result = $this->instance->recentErrors(); + + $this->assertCount(3, $result['entries']); + $this->assertSame('third', $result['entries'][0]['message']); + $this->assertSame('first', $result['entries'][2]['message']); + } finally { + unlink($path); + } + } +} diff --git a/tests/lib/LoginStatsTest.php b/tests/lib/LoginStatsTest.php new file mode 100644 index 00000000..ff23a98c --- /dev/null +++ b/tests/lib/LoginStatsTest.php @@ -0,0 +1,160 @@ +db = Server::get(IDBConnection::class); + $this->instance = new LoginStats($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 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(); + + 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 @@ + + + + + + + + + + From 2a370eacf4308e7645bba901c166dee66542107b Mon Sep 17 00:00:00 2001 From: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> Date: Tue, 12 May 2026 20:34:02 +0200 Subject: [PATCH 3/4] fixup! feat(services): add log and login/session services --- lib/LoginStats.php | 24 +++++++++++++++++++++++- tests/lib/LoginStatsTest.php | 32 +++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/lib/LoginStats.php b/lib/LoginStats.php index 9a089a50..4dd84b52 100644 --- a/lib/LoginStats.php +++ b/lib/LoginStats.php @@ -9,10 +9,12 @@ namespace OCA\ServerInfo; +use OCP\IConfig; use OCP\IDBConnection; class LoginStats { public function __construct( + private IConfig $config, private IDBConnection $db, ) { } @@ -23,10 +25,22 @@ public function __construct( * bruteforceAttempts1h: int, * bruteforceTotal: int, * topIps: list, - * available: bool + * 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) { @@ -48,6 +62,14 @@ public function getStats(): array { ]; } + 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'); diff --git a/tests/lib/LoginStatsTest.php b/tests/lib/LoginStatsTest.php index ff23a98c..be5cb56c 100644 --- a/tests/lib/LoginStatsTest.php +++ b/tests/lib/LoginStatsTest.php @@ -11,8 +11,10 @@ use OCA\ServerInfo\LoginStats; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IConfig; use OCP\IDBConnection; use OCP\Server; +use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; /** @@ -20,6 +22,7 @@ */ class LoginStatsTest extends TestCase { private IDBConnection $db; + private IConfig&MockObject $config; private LoginStats $instance; private const IP_A = '10.0.0.1'; @@ -29,7 +32,10 @@ class LoginStatsTest extends TestCase { protected function setUp(): void { parent::setUp(); $this->db = Server::get(IDBConnection::class); - $this->instance = new LoginStats($this->db); + $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(); } @@ -61,6 +67,29 @@ private function insertAttempt(string $ip, int $occurred): void { $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(); @@ -114,6 +143,7 @@ public function testTopIpsShape(): void { $result = $this->instance->getStats(); + $this->assertIsArray($result['topIps']); foreach ($result['topIps'] as $entry) { $this->assertArrayHasKey('ip', $entry); $this->assertArrayHasKey('count', $entry); From 4e880d0ff8bd2d747facbf54bd552e15615fec42 Mon Sep 17 00:00:00 2001 From: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> Date: Tue, 12 May 2026 20:56:17 +0200 Subject: [PATCH 4/4] fixup! feat(services): add log and login/session services --- lib/LogTailReader.php | 80 ++---------- tests/lib/LogTailReaderTest.php | 219 ++++++++++++-------------------- 2 files changed, 94 insertions(+), 205 deletions(-) diff --git a/lib/LogTailReader.php b/lib/LogTailReader.php index 0ad0ad16..c3fba5f5 100644 --- a/lib/LogTailReader.php +++ b/lib/LogTailReader.php @@ -10,19 +10,17 @@ namespace OCA\ServerInfo; use OCP\IConfig; +use OCP\Log\IFileBased; +use OCP\Log\ILogFactory; class LogTailReader { - private const READ_CHUNK = 96 * 1024; - public function __construct( private IConfig $config, + private ILogFactory $logFactory, ) { } /** - * Tail the Nextcloud JSON log and return the last $limit entries - * with severity >= $minLevel (default: WARN = 2). Skips DEBUG/INFO. - * * @return array{ * entries: list, * available: bool, @@ -35,87 +33,37 @@ public function recentErrors(int $limit = 8, int $minLevel = 2): array { return ['entries' => [], 'available' => false, 'reason' => 'log_type_not_file']; } - $path = $this->resolvePath(); - if ($path === null || !is_readable($path)) { + $log = $this->logFactory->get('file'); + if (!($log instanceof IFileBased)) { return ['entries' => [], 'available' => false, 'reason' => 'log_not_readable']; } - $tail = $this->tailFile($path, self::READ_CHUNK); - if ($tail === '') { - return ['entries' => [], 'available' => true]; - } - - $lines = explode("\n", $tail); + $raw = $log->getEntries($limit * 10); $collected = []; - // Iterate from newest to oldest. - for ($i = count($lines) - 1; $i >= 0 && count($collected) < $limit; $i--) { - $line = trim($lines[$i]); - if ($line === '') { - continue; + foreach ($raw as $entry) { + if (count($collected) >= $limit) { + break; } - $decoded = json_decode($line, true); - if (!is_array($decoded)) { - continue; - } - $level = isset($decoded['level']) ? (int)$decoded['level'] : 0; + $level = (int)($entry['level'] ?? 0); if ($level < $minLevel) { continue; } $collected[] = [ - 'time' => (string)($decoded['time'] ?? ''), + 'time' => (string)($entry['time'] ?? ''), 'level' => $level, - 'app' => (string)($decoded['app'] ?? ''), - 'message' => $this->snippet((string)($decoded['message'] ?? '')), + 'app' => (string)($entry['app'] ?? ''), + 'message' => $this->snippet((string)($entry['message'] ?? '')), ]; } return ['entries' => $collected, 'available' => true]; } - private function resolvePath(): ?string { - $dataDir = $this->config->getSystemValue('datadirectory', ''); - $default = $dataDir !== '' ? rtrim($dataDir, '/') . '/nextcloud.log' : ''; - $logFile = $this->config->getSystemValue('logfile', $default); - if (!is_string($logFile) || $logFile === '') { - return null; - } - return $logFile; - } - - private function tailFile(string $path, int $chunk): string { - $size = @filesize($path); - if ($size === false || $size === 0) { - return ''; - } - $handle = @fopen($path, 'rb'); - if ($handle === false) { - return ''; - } - try { - $readFrom = max(0, $size - $chunk); - fseek($handle, $readFrom); - $data = fread($handle, $chunk) ?: ''; - // Drop the leading partial line so JSON parsing doesn't choke. - if ($readFrom > 0) { - $nl = strpos($data, "\n"); - if ($nl !== false) { - $data = substr($data, $nl + 1); - } - } - return $data; - } finally { - fclose($handle); - } - } - private function snippet(string $msg, int $max = 200): string { $msg = trim($msg); - if (function_exists('mb_strlen') && mb_strlen($msg) > $max) { + if (mb_strlen($msg) > $max) { return mb_substr($msg, 0, $max - 1) . '…'; } - if (strlen($msg) > $max) { - return substr($msg, 0, $max - 1) . '…'; - } return $msg; } } diff --git a/tests/lib/LogTailReaderTest.php b/tests/lib/LogTailReaderTest.php index bb9beccb..064a3b6d 100644 --- a/tests/lib/LogTailReaderTest.php +++ b/tests/lib/LogTailReaderTest.php @@ -11,39 +11,34 @@ use OCA\ServerInfo\LogTailReader; use OCP\IConfig; +use OCP\Log\IFileBased; +use OCP\Log\ILogFactory; +use OCP\Log\IWriter; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; class LogTailReaderTest extends TestCase { private IConfig&MockObject $config; + private ILogFactory&MockObject $logFactory; private LogTailReader $instance; - private string $tmpDir; protected function setUp(): void { parent::setUp(); $this->config = $this->createMock(IConfig::class); - $this->instance = new LogTailReader($this->config); - $this->tmpDir = sys_get_temp_dir(); + $this->logFactory = $this->createMock(ILogFactory::class); + $this->instance = new LogTailReader($this->config, $this->logFactory); } - private function configReturns(string $logType, string $logFile = ''): void { - $this->config->method('getSystemValue') - ->willReturnCallback(function (string $key) use ($logType, $logFile): string { - return match ($key) { - 'log_type' => $logType, - 'datadirectory' => '', - 'logfile' => $logFile, - default => '', - }; - }); - } - - private function setupFileLog(string $path): void { - $this->configReturns('file', $path); + /** @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->configReturns('syslog'); + $this->config->method('getSystemValue')->with('log_type', 'file')->willReturn('syslog'); $result = $this->instance->recentErrors(); @@ -52,8 +47,10 @@ public function testNonFileLogTypeReturnsUnavailable(): void { $this->assertSame([], $result['entries']); } - public function testUnreadablePathReturnsUnavailable(): void { - $this->configReturns('file', '/nonexistent/path/nextcloud.log'); + 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(); @@ -61,143 +58,87 @@ public function testUnreadablePathReturnsUnavailable(): void { $this->assertSame('log_not_readable', $result['reason']); } - public function testEmptyLogFileReturnsAvailableWithNoEntries(): void { - $path = tempnam($this->tmpDir, 'nc_log_test_'); - file_put_contents($path, ''); - $this->setupFileLog($path); + public function testEmptyEntriesReturnsAvailableWithNoEntries(): void { + $this->setupFileLog([]); - try { - $result = $this->instance->recentErrors(); + $result = $this->instance->recentErrors(); - $this->assertTrue($result['available']); - $this->assertSame([], $result['entries']); - } finally { - unlink($path); - } + $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 { - $path = tempnam($this->tmpDir, 'nc_log_test_'); - $lines = [ - json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 0, 'app' => 'a', 'message' => 'debug']), - json_encode(['time' => '2026-01-01T00:00:01+00:00', 'level' => 1, 'app' => 'a', 'message' => 'info']), - json_encode(['time' => '2026-01-01T00:00:02+00:00', 'level' => 2, 'app' => 'a', 'message' => 'warn']), - json_encode(['time' => '2026-01-01T00:00:03+00:00', 'level' => 3, 'app' => 'a', 'message' => 'error']), - ]; - file_put_contents($path, implode("\n", $lines) . "\n"); - $this->setupFileLog($path); - - try { - $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']); - } - } finally { - unlink($path); + $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 { - $path = tempnam($this->tmpDir, 'nc_log_test_'); - $lines = []; + $entries = []; for ($i = 0; $i < 10; $i++) { - $lines[] = json_encode(['time' => "2026-01-01T00:00:{$i}0+00:00", 'level' => 3, 'app' => 'test', 'message' => "error $i"]); + $entries[] = ['time' => "2026-01-01T00:00:{$i}0+00:00", 'level' => 3, 'app' => 'test', 'message' => "error $i"]; } - file_put_contents($path, implode("\n", $lines) . "\n"); - $this->setupFileLog($path); + $this->setupFileLog($entries); - try { - $result = $this->instance->recentErrors(limit: 3); + $result = $this->instance->recentErrors(limit: 3); - $this->assertTrue($result['available']); - $this->assertCount(3, $result['entries']); - } finally { - unlink($path); - } - } - - public function testReturnShape(): void { - $path = tempnam($this->tmpDir, 'nc_log_test_'); - $line = json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'something failed']); - file_put_contents($path, $line . "\n"); - $this->setupFileLog($path); - - try { - $result = $this->instance->recentErrors(); - - $this->assertArrayHasKey('entries', $result); - $this->assertArrayHasKey('available', $result); - $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']); - } finally { - unlink($path); - } + $this->assertTrue($result['available']); + $this->assertCount(3, $result['entries']); } public function testLongMessageIsTruncated(): void { - $path = tempnam($this->tmpDir, 'nc_log_test_'); - $longMsg = str_repeat('a', 300); - $line = json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => $longMsg]); - file_put_contents($path, $line . "\n"); - $this->setupFileLog($path); - - try { - $result = $this->instance->recentErrors(); - - $this->assertCount(1, $result['entries']); - // snippet() uses mb_strlen/mb_substr so measure in characters, not bytes - $this->assertLessThanOrEqual(200, mb_strlen($result['entries'][0]['message'])); - } finally { - unlink($path); - } - } + $this->setupFileLog([ + ['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => str_repeat('a', 300)], + ]); - public function testInvalidJsonLinesAreSkipped(): void { - $path = tempnam($this->tmpDir, 'nc_log_test_'); - $lines = [ - 'not valid json', - json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'core', 'message' => 'real error']), - '{broken', - ]; - file_put_contents($path, implode("\n", $lines) . "\n"); - $this->setupFileLog($path); - - try { - $result = $this->instance->recentErrors(); - - $this->assertCount(1, $result['entries']); - } finally { - unlink($path); - } + $result = $this->instance->recentErrors(); + + $this->assertCount(1, $result['entries']); + $this->assertLessThanOrEqual(200, mb_strlen($result['entries'][0]['message'])); } - public function testEntriesReturnedNewestFirst(): void { - $path = tempnam($this->tmpDir, 'nc_log_test_'); - $lines = [ - json_encode(['time' => '2026-01-01T00:00:00+00:00', 'level' => 3, 'app' => 'a', 'message' => 'first']), - json_encode(['time' => '2026-01-01T00:00:01+00:00', 'level' => 3, 'app' => 'a', 'message' => 'second']), - json_encode(['time' => '2026-01-01T00:00:02+00:00', 'level' => 3, 'app' => 'a', 'message' => 'third']), - ]; - file_put_contents($path, implode("\n", $lines) . "\n"); - $this->setupFileLog($path); - - try { - $result = $this->instance->recentErrors(); - - $this->assertCount(3, $result['entries']); - $this->assertSame('third', $result['entries'][0]['message']); - $this->assertSame('first', $result['entries'][2]['message']); - } finally { - unlink($path); - } + 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']); } }