diff --git a/lib/AuditLog/AuditEventFactory.php b/lib/AuditLog/AuditEventFactory.php new file mode 100644 index 00000000..fdcec962 --- /dev/null +++ b/lib/AuditLog/AuditEventFactory.php @@ -0,0 +1,25 @@ + $parameters values for the placeholders of $message, in order, optionally followed by more context + */ + public static function create(Operation $operation, string $message, array $parameters = []): CriticalActionPerformedEvent { + $arguments = [$message, $parameters, false]; + if (method_exists(CriticalActionPerformedEvent::class, 'getOperation')) { + $arguments[] = $operation->value; + } + return new CriticalActionPerformedEvent(...$arguments); + } +} diff --git a/lib/AuditLog/Operation.php b/lib/AuditLog/Operation.php new file mode 100644 index 00000000..92b5c8b0 --- /dev/null +++ b/lib/AuditLog/Operation.php @@ -0,0 +1,37 @@ + 'An admin changed a Recognize setting to a different value.', + self::ClassificationsReset => 'Recognize classification tags were removed from all files of all users.', + self::FaceClustersDeletedAll => 'All face clusters (persons) of all users were deleted.', + self::FaceDetectionsDeletedAll => 'All face detections of all users were deleted.', + self::EmptyTagsDeleted => 'System tags that were assigned to no file were deleted, including tags not created by Recognize.', + self::LegacyTagsDeleted => 'System tags created by old Recognize versions were deleted.', + self::QueuesCleared => 'All Recognize queues and scheduled background jobs were cleared.', + self::RecrawlScheduled => 'All Recognize queues and background jobs were cleared and a new crawl of all files was scheduled.', + }; + } +} diff --git a/lib/Command/ClearBackgroundJobs.php b/lib/Command/ClearBackgroundJobs.php index e00bd575..5b0f00cd 100644 --- a/lib/Command/ClearBackgroundJobs.php +++ b/lib/Command/ClearBackgroundJobs.php @@ -7,29 +7,19 @@ declare(strict_types=1); namespace OCA\Recognize\Command; -use OCA\Recognize\BackgroundJobs\ClassifyFacesJob; -use OCA\Recognize\BackgroundJobs\ClassifyImagenetJob; -use OCA\Recognize\BackgroundJobs\ClassifyLandmarksJob; -use OCA\Recognize\BackgroundJobs\ClassifyMovinetJob; -use OCA\Recognize\BackgroundJobs\ClassifyMusicnnJob; -use OCA\Recognize\BackgroundJobs\SchedulerJob; -use OCA\Recognize\BackgroundJobs\StorageCrawlJob; use OCA\Recognize\Service\QueueService; -use OCP\BackgroundJob\IJobList; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; final class ClearBackgroundJobs extends Command { - private IJobList $jobList; private LoggerInterface $logger; private QueueService $queue; - public function __construct(IJobList $jobList, LoggerInterface $logger, QueueService $queue) { + public function __construct(LoggerInterface $logger, QueueService $queue) { parent::__construct(); - $this->jobList = $jobList; $this->logger = $logger; $this->queue = $queue; } @@ -54,18 +44,7 @@ protected function configure() { */ protected function execute(InputInterface $input, OutputInterface $output): int { try { - $this->queue->clearQueue('imagenet'); - $this->queue->clearQueue('faces'); - $this->queue->clearQueue('landmarks'); - $this->queue->clearQueue('movinet'); - $this->queue->clearQueue('musicnn'); - $this->jobList->remove(ClassifyFacesJob::class); - $this->jobList->remove(ClassifyImagenetJob::class); - $this->jobList->remove(ClassifyLandmarksJob::class); - $this->jobList->remove(ClassifyMusicnnJob::class); - $this->jobList->remove(ClassifyMovinetJob::class); - $this->jobList->remove(SchedulerJob::class); - $this->jobList->remove(StorageCrawlJob::class); + $this->queue->clearAllJobs(); } catch (\Exception $ex) { $output->writeln('Failed to clear background jobs'); $output->writeln($ex->getMessage()); diff --git a/lib/Command/Recrawl.php b/lib/Command/Recrawl.php index 1790972d..68aba846 100644 --- a/lib/Command/Recrawl.php +++ b/lib/Command/Recrawl.php @@ -7,29 +7,18 @@ declare(strict_types=1); namespace OCA\Recognize\Command; -use OCA\Recognize\BackgroundJobs\ClassifyFacesJob; -use OCA\Recognize\BackgroundJobs\ClassifyImagenetJob; -use OCA\Recognize\BackgroundJobs\ClassifyLandmarksJob; -use OCA\Recognize\BackgroundJobs\ClassifyMovinetJob; -use OCA\Recognize\BackgroundJobs\ClassifyMusicnnJob; -use OCA\Recognize\BackgroundJobs\ClusterFacesJob; -use OCA\Recognize\BackgroundJobs\SchedulerJob; -use OCA\Recognize\BackgroundJobs\StorageCrawlJob; use OCA\Recognize\Service\QueueService; -use OCP\BackgroundJob\IJobList; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; final class Recrawl extends Command { - private IJobList $jobList; private LoggerInterface $logger; private QueueService $queue; - public function __construct(IJobList $jobList, LoggerInterface $logger, QueueService $queue) { + public function __construct(LoggerInterface $logger, QueueService $queue) { parent::__construct(); - $this->jobList = $jobList; $this->logger = $logger; $this->queue = $queue; } @@ -54,20 +43,7 @@ protected function configure() { */ protected function execute(InputInterface $input, OutputInterface $output): int { try { - $this->queue->clearQueue('imagenet'); - $this->queue->clearQueue('faces'); - $this->queue->clearQueue('landmarks'); - $this->queue->clearQueue('movinet'); - $this->queue->clearQueue('musicnn'); - $this->jobList->remove(ClassifyFacesJob::class); - $this->jobList->remove(ClassifyImagenetJob::class); - $this->jobList->remove(ClassifyLandmarksJob::class); - $this->jobList->remove(ClassifyMusicnnJob::class); - $this->jobList->remove(ClassifyMovinetJob::class); - $this->jobList->remove(ClusterFacesJob::class); - $this->jobList->remove(SchedulerJob::class); - $this->jobList->remove(StorageCrawlJob::class); - $this->jobList->add(SchedulerJob::class); + $this->queue->scheduleRecrawl(); } catch (\Exception $ex) { $output->writeln('Failed to schedule recrawl'); $output->writeln($ex->getMessage()); diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 0b1a149e..0aee1ac1 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -15,8 +15,6 @@ use OCA\Recognize\BackgroundJobs\ClassifyMovinetJob; use OCA\Recognize\BackgroundJobs\ClassifyMusicnnJob; use OCA\Recognize\BackgroundJobs\ClusterFacesJob; -use OCA\Recognize\BackgroundJobs\SchedulerJob; -use OCA\Recognize\BackgroundJobs\StorageCrawlJob; use OCA\Recognize\Db\FaceClusterMapper; use OCA\Recognize\Db\FaceDetectionMapper; use OCA\Recognize\Service\QueueService; @@ -63,25 +61,12 @@ public function reset(): JSONResponse { } public function clearAllJobs(): JSONResponse { - $this->queue->clearQueue('imagenet'); - $this->queue->clearQueue('faces'); - $this->queue->clearQueue('landmarks'); - $this->queue->clearQueue('movinet'); - $this->queue->clearQueue('musicnn'); - $this->jobList->remove(ClassifyFacesJob::class); - $this->jobList->remove(ClassifyImagenetJob::class); - $this->jobList->remove(ClassifyLandmarksJob::class); - $this->jobList->remove(ClassifyMusicnnJob::class); - $this->jobList->remove(ClassifyMovinetJob::class); - $this->jobList->remove(ClusterFacesJob::class); - $this->jobList->remove(SchedulerJob::class); - $this->jobList->remove(StorageCrawlJob::class); + $this->queue->clearAllJobs(); return new JSONResponse([]); } public function recrawl(): JSONResponse { - $this->clearAllJobs(); - $this->jobList->add(SchedulerJob::class); + $this->queue->scheduleRecrawl(); return new JSONResponse([]); } diff --git a/lib/Db/FaceClusterMapper.php b/lib/Db/FaceClusterMapper.php index 0212a12a..99a15e6d 100644 --- a/lib/Db/FaceClusterMapper.php +++ b/lib/Db/FaceClusterMapper.php @@ -7,16 +7,22 @@ declare(strict_types=1); namespace OCA\Recognize\Db; +use OCA\Recognize\AuditLog\AuditEventFactory; +use OCA\Recognize\AuditLog\Operation; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; /** * @template-extends QBMapper */ final class FaceClusterMapper extends QBMapper { - public function __construct(IDBConnection $db) { + public function __construct( + IDBConnection $db, + private IEventDispatcher $eventDispatcher, + ) { parent::__construct($db, 'recognize_face_clusters', FaceCluster::class); $this->db = $db; } @@ -104,7 +110,14 @@ public function delete(Entity $entity): Entity { */ public function deleteAll(): void { $qb = $this->db->getQueryBuilder(); - $qb->delete('recognize_face_clusters') + $count = $qb->delete('recognize_face_clusters') ->executeStatement(); + if ($count > 0) { + $this->eventDispatcher->dispatchTyped(AuditEventFactory::create( + Operation::FaceClustersDeletedAll, + 'Deleted all %s Recognize face clusters', + ['count' => $count], + )); + } } } diff --git a/lib/Db/FaceDetectionMapper.php b/lib/Db/FaceDetectionMapper.php index f5906e1f..9722630f 100644 --- a/lib/Db/FaceDetectionMapper.php +++ b/lib/Db/FaceDetectionMapper.php @@ -7,6 +7,8 @@ declare(strict_types=1); namespace OCA\Recognize\Db; +use OCA\Recognize\AuditLog\AuditEventFactory; +use OCA\Recognize\AuditLog\Operation; use OCA\Recognize\Classifiers\TaskProcessing\ImageFaceRecognitionClassifier; use OCA\Recognize\Service\FaceClusterAnalyzer; use OCA\Recognize\Service\SettingsService; @@ -15,6 +17,7 @@ use OCP\AppFramework\Db\QBMapper; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; @@ -24,12 +27,14 @@ final class FaceDetectionMapper extends QBMapper { private IConfig $config; private SettingsService $settingsService; + private IEventDispatcher $eventDispatcher; - public function __construct(IDBConnection $db, IConfig $config, SettingsService $settingsService) { + public function __construct(IDBConnection $db, IConfig $config, SettingsService $settingsService, IEventDispatcher $eventDispatcher) { parent::__construct($db, 'recognize_face_detections', FaceDetection::class); $this->db = $db; $this->config = $config; $this->settingsService = $settingsService; + $this->eventDispatcher = $eventDispatcher; } private function getMinDetectionSize(): float { @@ -91,8 +96,15 @@ public function insertWithoutDeduplication(Entity $entity): FaceDetection { */ public function deleteAll(): void { $qb = $this->db->getQueryBuilder(); - $qb->delete('recognize_face_detections') + $count = $qb->delete('recognize_face_detections') ->executeStatement(); + if ($count > 0) { + $this->eventDispatcher->dispatchTyped(AuditEventFactory::create( + Operation::FaceDetectionsDeletedAll, + 'Deleted all %s Recognize face detections', + ['count' => $count], + )); + } } /** diff --git a/lib/Db/QueueMapper.php b/lib/Db/QueueMapper.php index ffa93fa7..7b8a6be2 100644 --- a/lib/Db/QueueMapper.php +++ b/lib/Db/QueueMapper.php @@ -163,9 +163,13 @@ public function insertIntoQueue(string $model, QueueFile $file) : QueueFile { return $insertedFile; } - public function clearQueue(string $model): void { + /** + * @return int the number of removed queue entries + * @throws \OCP\DB\Exception + */ + public function clearQueue(string $model): int { $qb = $this->db->getQueryBuilder(); - $qb->delete($this->getQueueTable($model))->executeStatement(); + return $qb->delete($this->getQueueTable($model))->executeStatement(); } /** diff --git a/lib/Service/QueueService.php b/lib/Service/QueueService.php index bffee7e7..70b91e59 100644 --- a/lib/Service/QueueService.php +++ b/lib/Service/QueueService.php @@ -7,11 +7,16 @@ declare(strict_types=1); namespace OCA\Recognize\Service; +use OCA\Recognize\AuditLog\AuditEventFactory; +use OCA\Recognize\AuditLog\Operation; use OCA\Recognize\BackgroundJobs\ClassifyFacesJob; use OCA\Recognize\BackgroundJobs\ClassifyImagenetJob; use OCA\Recognize\BackgroundJobs\ClassifyLandmarksJob; use OCA\Recognize\BackgroundJobs\ClassifyMovinetJob; use OCA\Recognize\BackgroundJobs\ClassifyMusicnnJob; +use OCA\Recognize\BackgroundJobs\ClusterFacesJob; +use OCA\Recognize\BackgroundJobs\SchedulerJob; +use OCA\Recognize\BackgroundJobs\StorageCrawlJob; use OCA\Recognize\Classifiers\Audio\MusicnnClassifier; use OCA\Recognize\Classifiers\Images\ClusteringFaceClassifier; use OCA\Recognize\Classifiers\Images\ImagenetClassifier; @@ -21,6 +26,7 @@ use OCA\Recognize\Db\QueueMapper; use OCP\AppFramework\Services\IAppConfig; use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\IEventDispatcher; final class QueueService { /** @@ -37,11 +43,13 @@ final class QueueService { private QueueMapper $queueMapper; private IJobList $jobList; private IAppConfig $config; + private IEventDispatcher $eventDispatcher; - public function __construct(QueueMapper $queueMapper, IJobList $jobList, IAppConfig $config) { + public function __construct(QueueMapper $queueMapper, IJobList $jobList, IAppConfig $config, IEventDispatcher $eventDispatcher) { $this->queueMapper = $queueMapper; $this->jobList = $jobList; $this->config = $config; + $this->eventDispatcher = $eventDispatcher; } /** @@ -129,6 +137,53 @@ public function clearQueue(string $model): void { $this->queueMapper->clearQueue($model); } + /** + * Empties all queues and removes all scheduled Recognize background jobs + * + * @throws \OCP\DB\Exception + */ + public function clearAllJobs(): void { + $removedFiles = $this->removeAllJobs(); + $this->eventDispatcher->dispatchTyped(AuditEventFactory::create( + Operation::QueuesCleared, + 'Cleared all Recognize queues and background jobs, removing %s queued files', + ['removedFiles' => $removedFiles], + )); + } + + /** + * Empties all queues, removes all scheduled Recognize background jobs and schedules a new crawl of all files + * + * @throws \OCP\DB\Exception + */ + public function scheduleRecrawl(): void { + $removedFiles = $this->removeAllJobs(); + $this->jobList->add(SchedulerJob::class); + $this->eventDispatcher->dispatchTyped(AuditEventFactory::create( + Operation::RecrawlScheduled, + 'Scheduled a Recognize recrawl of all files, removing %s queued files', + ['removedFiles' => $removedFiles], + )); + } + + /** + * @return int the number of removed queue entries + * @throws \OCP\DB\Exception + */ + private function removeAllJobs(): int { + $removedFiles = 0; + foreach (array_keys(self::JOB_CLASSES) as $model) { + $removedFiles += $this->queueMapper->clearQueue($model); + } + foreach (self::JOB_CLASSES as $jobClass) { + $this->jobList->remove($jobClass); + } + $this->jobList->remove(ClusterFacesJob::class); + $this->jobList->remove(SchedulerJob::class); + $this->jobList->remove(StorageCrawlJob::class); + return $removedFiles; + } + public function count(string $model): int { return $this->queueMapper->count($model); } diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index adfeb1fd..abb0bfea 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -9,6 +9,8 @@ namespace OCA\Recognize\Service; +use OCA\Recognize\AuditLog\AuditEventFactory; +use OCA\Recognize\AuditLog\Operation; use OCA\Recognize\BackgroundJobs\SchedulerJob; use OCA\Recognize\Classifiers\Audio\MusicnnClassifier; use OCA\Recognize\Classifiers\Images\ClusteringFaceClassifier; @@ -18,6 +20,7 @@ use OCA\Recognize\Exception\Exception; use OCP\AppFramework\Services\IAppConfig; use OCP\BackgroundJob\IJobList; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Server; final class SettingsService { @@ -99,12 +102,32 @@ final class SettingsService { 'clusterFaces.concurrency', ]; + /** + * Progress state written by the background jobs, not configuration + */ + private const STATE_SETTINGS = [ + 'clusterFaces.status', + 'faces.status', + 'imagenet.status', + 'landmarks.status', + 'movinet.status', + 'musicnn.status', + 'clusterFaces.lastRun', + 'faces.lastFile', + 'imagenet.lastFile', + 'landmarks.lastFile', + 'movinet.lastFile', + 'musicnn.lastFile', + ]; + private IAppConfig $config; private IJobList $jobList; + private IEventDispatcher $eventDispatcher; - public function __construct(IAppConfig $config, IJobList $jobList) { + public function __construct(IAppConfig $config, IJobList $jobList, IEventDispatcher $eventDispatcher) { $this->config = $config; $this->jobList = $jobList; + $this->eventDispatcher = $eventDispatcher; } /** @@ -158,6 +181,8 @@ public function setSetting(string $key, string $value): void { if (!array_key_exists($key, self::DEFAULTS)) { throw new Exception('Unknown settings key '.$key); } + $isConfiguration = !in_array($key, self::STATE_SETTINGS, true); + $oldValue = $isConfiguration ? $this->getSetting($key) : null; if ($value === 'true' && $this->config->getAppValueString($key, 'false') === 'false') { // Additional model enabled: Schedule new crawl run for the affected mime types switch ($key) { @@ -185,6 +210,15 @@ public function setSetting(string $key, string $value): void { $lazy = true; } $this->config->setAppValueString($key, $value, lazy: $lazy); + + // The admin UI saves all settings at once, so only actual changes are logged + if ($isConfiguration && $oldValue !== $value) { + $this->eventDispatcher->dispatchTyped(AuditEventFactory::create( + Operation::SettingChanged, + 'Recognize setting "%s" was changed to "%s"', + ['key' => $key, 'value' => $value, 'oldValue' => $oldValue], + )); + } } /** diff --git a/lib/Service/TagManager.php b/lib/Service/TagManager.php index 99fd5abd..9dd61059 100644 --- a/lib/Service/TagManager.php +++ b/lib/Service/TagManager.php @@ -7,6 +7,9 @@ declare(strict_types=1); namespace OCA\Recognize\Service; +use OCA\Recognize\AuditLog\AuditEventFactory; +use OCA\Recognize\AuditLog\Operation; +use OCP\EventDispatcher\IEventDispatcher; use OCP\SystemTag\ISystemTag; use OCP\SystemTag\ISystemTagManager; use OCP\SystemTag\ISystemTagObjectMapper; @@ -17,10 +20,12 @@ final class TagManager { private ISystemTagManager $tagManager; private ISystemTagObjectMapper $objectMapper; + private IEventDispatcher $eventDispatcher; - public function __construct(ISystemTagManager $systemTagManager, ISystemTagObjectMapper $objectMapper) { + public function __construct(ISystemTagManager $systemTagManager, ISystemTagObjectMapper $objectMapper, IEventDispatcher $eventDispatcher) { $this->tagManager = $systemTagManager; $this->objectMapper = $objectMapper; + $this->eventDispatcher = $eventDispatcher; } /** @@ -138,6 +143,13 @@ public function resetClassifications(): void { foreach ($fileIds as $id) { $this->objectMapper->unassignTags($id, 'files', $tagIds); } + if (count($fileIds) > 0) { + $this->eventDispatcher->dispatchTyped(AuditEventFactory::create( + Operation::ClassificationsReset, + 'Removed Recognize classification tags from %s files', + ['fileCount' => count($fileIds)], + )); + } } /** @@ -145,20 +157,40 @@ public function resetClassifications(): void { */ public function removeEmptyTags(): void { $tags = $this->tagManager->getAllTags(); + $deletedTagIds = []; foreach ($tags as $tag) { $files = $this->objectMapper->getObjectIdsForTags($tag->getId(), 'files', 1); if (empty($files)) { $this->tagManager->deleteTags($tag->getId()); + $deletedTagIds[] = $tag->getId(); } } + $this->logDeletedTags(Operation::EmptyTagsDeleted, 'Deleted %s empty system tags', $deletedTagIds); } public function removeTags(array $tagNames) :void { $tags = $this->tagManager->getAllTags(); + $deletedTagIds = []; foreach ($tags as $tag) { if (in_array($tag->getName(), $tagNames)) { $this->tagManager->deleteTags($tag->getId()); + $deletedTagIds[] = $tag->getId(); } } + $this->logDeletedTags(Operation::LegacyTagsDeleted, 'Deleted %s legacy Recognize system tags', $deletedTagIds); + } + + /** + * @param list $tagIds + */ + private function logDeletedTags(Operation $operation, string $message, array $tagIds): void { + if (count($tagIds) === 0) { + return; + } + $this->eventDispatcher->dispatchTyped(AuditEventFactory::create( + $operation, + $message, + ['count' => count($tagIds), 'tagIds' => $tagIds], + )); } } diff --git a/tests/AuditLog/AuditLogTestHelper.php b/tests/AuditLog/AuditLogTestHelper.php new file mode 100644 index 00000000..b52f1018 --- /dev/null +++ b/tests/AuditLog/AuditLogTestHelper.php @@ -0,0 +1,19 @@ +getOperation() === $operation; + } +} diff --git a/tests/AuditLog/OperationTest.php b/tests/AuditLog/OperationTest.php new file mode 100644 index 00000000..e2c9b5f6 --- /dev/null +++ b/tests/AuditLog/OperationTest.php @@ -0,0 +1,32 @@ + [$operation], Operation::cases()); + } + + /** + * @dataProvider operationProvider + */ + public function testOperationFormat(Operation $operation): void { + $this->assertMatchesRegularExpression('/^recognize\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/', $operation->value); + } + + /** + * @dataProvider operationProvider + */ + public function testOperationHasDescription(Operation $operation): void { + $this->assertNotSame('', trim($operation->description())); + } +} diff --git a/tests/Db/FaceMapperAuditTest.php b/tests/Db/FaceMapperAuditTest.php new file mode 100644 index 00000000..73e65387 --- /dev/null +++ b/tests/Db/FaceMapperAuditTest.php @@ -0,0 +1,107 @@ +db = Server::get(IDBConnection::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->clusterMapper = new FaceClusterMapper($this->db, $this->eventDispatcher); + $this->detectionMapper = new FaceDetectionMapper($this->db, Server::get(IConfig::class), Server::get(SettingsService::class), $this->eventDispatcher); + $this->db->getQueryBuilder()->delete('recognize_face_clusters')->executeStatement(); + $this->db->getQueryBuilder()->delete('recognize_face_detections')->executeStatement(); + } + + protected function tearDown(): void { + $this->db->getQueryBuilder()->delete('recognize_face_clusters')->executeStatement(); + $this->db->getQueryBuilder()->delete('recognize_face_detections')->executeStatement(); + parent::tearDown(); + } + + public function testDeleteAllClustersIsLogged(): void { + foreach (['user1', 'user2'] as $userId) { + $cluster = new FaceCluster(); + $cluster->setUserId($userId); + $cluster->setTitle(''); + $this->clusterMapper->insert($cluster); + } + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Deleted all %s Recognize face clusters', $event->getLogMessage()); + $this->assertSame(['count' => 2], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.face_clusters.deleted_all')); + return true; + })); + + $this->clusterMapper->deleteAll(); + } + + public function testDeleteAllClustersWithoutClustersIsNotLogged(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->clusterMapper->deleteAll(); + } + + public function testDeleteAllDetectionsIsLogged(): void { + for ($i = 0; $i < 3; $i++) { + $detection = new FaceDetection(); + $detection->setUserId('user' . $i); + $detection->setX(0.1 * $i); + $detection->setY(0.1 * $i); + $detection->setHeight(0.5); + $detection->setWidth(0.5); + $detection->setFileId($i); + $detection->setThreshold(0.5); + $detection->setVector([1, 2, 3, 4, 5, 6, 7, 8, 9, 0]); + $this->detectionMapper->insertWithoutDeduplication($detection); + } + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Deleted all %s Recognize face detections', $event->getLogMessage()); + $this->assertSame(['count' => 3], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.face_detections.deleted_all')); + return true; + })); + + $this->detectionMapper->deleteAll(); + } + + public function testDeleteAllDetectionsWithoutDetectionsIsNotLogged(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->detectionMapper->deleteAll(); + } +} diff --git a/tests/Service/QueueServiceAuditTest.php b/tests/Service/QueueServiceAuditTest.php new file mode 100644 index 00000000..0387a009 --- /dev/null +++ b/tests/Service/QueueServiceAuditTest.php @@ -0,0 +1,107 @@ +queueMapper = new QueueMapper(Server::get(IDBConnection::class)); + $this->jobList = $this->createMock(IJobList::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->service = new QueueService($this->queueMapper, $this->jobList, $this->createMock(IAppConfig::class), $this->eventDispatcher); + foreach (self::MODELS as $model) { + $this->queueMapper->clearQueue($model); + } + } + + protected function tearDown(): void { + foreach (self::MODELS as $model) { + $this->queueMapper->clearQueue($model); + } + parent::tearDown(); + } + + private function queueFile(string $model, int $fileId): void { + $file = new QueueFile(); + $file->setFileId($fileId); + $file->setStorageId(1); + $file->setRootId(1); + $file->setUpdate(false); + $this->queueMapper->insertIntoQueue($model, $file); + } + + public function testClearAllJobsIsLogged(): void { + $this->queueFile('imagenet', 1); + $this->queueFile('imagenet', 2); + $this->queueFile('faces', 1); + $this->jobList->expects($this->never())->method('add'); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Cleared all Recognize queues and background jobs, removing %s queued files', $event->getLogMessage()); + $this->assertSame(['removedFiles' => 3], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.queues.cleared')); + return true; + })); + + $this->service->clearAllJobs(); + foreach (self::MODELS as $model) { + $this->assertSame(0, $this->queueMapper->count($model)); + } + } + + public function testScheduleRecrawlIsLogged(): void { + $this->queueFile('musicnn', 1); + $this->jobList->expects($this->once())->method('add')->with(SchedulerJob::class); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Scheduled a Recognize recrawl of all files, removing %s queued files', $event->getLogMessage()); + $this->assertSame(['removedFiles' => 1], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.recrawl.scheduled')); + return true; + })); + + $this->service->scheduleRecrawl(); + } + + public function testFailedRecrawlIsNotLogged(): void { + $this->jobList->method('add')->willThrowException(new \RuntimeException('failed')); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + $this->expectException(\RuntimeException::class); + + $this->service->scheduleRecrawl(); + } +} diff --git a/tests/Service/SettingsServiceAuditTest.php b/tests/Service/SettingsServiceAuditTest.php new file mode 100644 index 00000000..e210e0c7 --- /dev/null +++ b/tests/Service/SettingsServiceAuditTest.php @@ -0,0 +1,98 @@ + */ + private array $stored = []; + + protected function setUp(): void { + parent::setUp(); + $this->stored = []; + $this->config = $this->createMock(IAppConfig::class); + $this->config->method('getAppValueString') + ->willReturnCallback(fn (string $key, string $default = '', bool $lazy = false): string => $this->stored[$key] ?? $default); + $this->config->method('setAppValueString') + ->willReturnCallback(function (string $key, string $value, bool $lazy = false): bool { + $this->stored[$key] = $value; + return true; + }); + $this->jobList = $this->createMock(IJobList::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->service = new SettingsService($this->config, $this->jobList, $this->eventDispatcher); + } + + public function testChangedSettingIsLogged(): void { + $this->stored['node_binary'] = '/usr/bin/node'; + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Recognize setting "%s" was changed to "%s"', $event->getLogMessage()); + $this->assertSame(['key' => 'node_binary', 'value' => '/opt/node/bin/node', 'oldValue' => '/usr/bin/node'], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.setting.changed')); + return true; + })); + + $this->service->setSetting('node_binary', '/opt/node/bin/node'); + } + + public function testChangeFromDefaultIsLogged(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame(['key' => 'tensorflow.cores', 'value' => '4', 'oldValue' => '0'], $event->getParameters()); + return true; + })); + + $this->service->setSetting('tensorflow.cores', '4'); + } + + public function testUnchangedSettingIsNotLogged(): void { + $this->stored['faces.enabled'] = 'true'; + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->service->setSetting('faces.enabled', 'true'); + } + + public function testSettingDefaultValueIsNotLogged(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->service->setSetting('imagenet.enabled', 'false'); + } + + public function testStateSettingsAreNotLogged(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->service->setSetting('faces.status', 'true'); + $this->service->setSetting('faces.lastFile', '1700000000'); + $this->service->setSetting('clusterFaces.lastRun', '1700000000'); + } + + public function testUnknownSettingIsNotLogged(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + $this->expectException(Exception::class); + + $this->service->setSetting('unknown', 'value'); + } +} diff --git a/tests/Service/TagManagerAuditTest.php b/tests/Service/TagManagerAuditTest.php new file mode 100644 index 00000000..9b5e58e5 --- /dev/null +++ b/tests/Service/TagManagerAuditTest.php @@ -0,0 +1,156 @@ +systemTagManager = $this->createMock(ISystemTagManager::class); + $this->objectMapper = $this->createMock(ISystemTagObjectMapper::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->tagManager = new TagManager($this->systemTagManager, $this->objectMapper, $this->eventDispatcher); + } + + private function createTag(string $id, string $name = 'tag'): ISystemTag&MockObject { + $tag = $this->createMock(ISystemTag::class); + $tag->method('getId')->willReturn($id); + $tag->method('getName')->willReturn($name); + return $tag; + } + + private function mockProcessedTag(): void { + $processedTag = $this->createTag('1', TagManager::RECOGNIZED_TAG); + $this->systemTagManager->method('getTag') + ->willReturnCallback(function (string $name) use ($processedTag): ISystemTag { + if ($name === TagManager::RECOGNIZED_TAG) { + return $processedTag; + } + throw new TagNotFoundException(); + }); + } + + public function testResetClassificationsIsLogged(): void { + $this->mockProcessedTag(); + $this->objectMapper->method('getObjectIdsForTags')->willReturn(['10', '11', '12']); + $this->objectMapper->expects($this->exactly(3))->method('unassignTags'); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Removed Recognize classification tags from %s files', $event->getLogMessage()); + $this->assertSame(['fileCount' => 3], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.classifications.reset')); + return true; + })); + + $this->tagManager->resetClassifications(); + } + + public function testResetClassificationsWithoutClassifiedFilesIsNotLogged(): void { + $this->mockProcessedTag(); + $this->objectMapper->method('getObjectIdsForTags')->willReturn([]); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->tagManager->resetClassifications(); + } + + public function testFailedResetClassificationsIsNotLogged(): void { + $this->mockProcessedTag(); + $this->objectMapper->method('getObjectIdsForTags')->willReturn(['10']); + $this->objectMapper->method('unassignTags')->willThrowException(new \RuntimeException('failed')); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + $this->expectException(\RuntimeException::class); + + $this->tagManager->resetClassifications(); + } + + public function testRemoveEmptyTagsIsLogged(): void { + $this->systemTagManager->method('getAllTags')->willReturn([ + $this->createTag('5'), + $this->createTag('6'), + $this->createTag('7'), + ]); + $this->objectMapper->method('getObjectIdsForTags') + ->willReturnCallback(fn (string $tagId): array => $tagId === '6' ? ['42'] : []); + $this->systemTagManager->expects($this->exactly(2))->method('deleteTags'); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Deleted %s empty system tags', $event->getLogMessage()); + $this->assertSame(['count' => 2, 'tagIds' => ['5', '7']], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.empty_tags.deleted')); + return true; + })); + + $this->tagManager->removeEmptyTags(); + } + + public function testRemoveEmptyTagsWithoutEmptyTagsIsNotLogged(): void { + $this->systemTagManager->method('getAllTags')->willReturn([$this->createTag('5')]); + $this->objectMapper->method('getObjectIdsForTags')->willReturn(['42']); + $this->systemTagManager->expects($this->never())->method('deleteTags'); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->tagManager->removeEmptyTags(); + } + + public function testFailedRemoveEmptyTagsIsNotLogged(): void { + $this->systemTagManager->method('getAllTags')->willReturn([$this->createTag('5')]); + $this->objectMapper->method('getObjectIdsForTags')->willReturn([]); + $this->systemTagManager->method('deleteTags')->willThrowException(new TagNotFoundException()); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + $this->expectException(TagNotFoundException::class); + + $this->tagManager->removeEmptyTags(); + } + + public function testRemoveLegacyTagsIsLogged(): void { + $this->systemTagManager->method('getAllTags')->willReturn([ + $this->createTag('5', 'cat'), + $this->createTag('6', 'holiday 2024'), + $this->createTag('7', 'dog'), + ]); + $this->systemTagManager->expects($this->exactly(2))->method('deleteTags'); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->callback(function (CriticalActionPerformedEvent $event): bool { + $this->assertSame('Deleted %s legacy Recognize system tags', $event->getLogMessage()); + $this->assertSame(['count' => 2, 'tagIds' => ['5', '7']], $event->getParameters()); + $this->assertTrue(AuditLogTestHelper::hasOperation($event, 'recognize.legacy_tags.deleted')); + return true; + })); + + $this->tagManager->removeTags(['cat', 'dog']); + } + + public function testRemoveLegacyTagsWithoutMatchIsNotLogged(): void { + $this->systemTagManager->method('getAllTags')->willReturn([$this->createTag('6', 'holiday 2024')]); + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->tagManager->removeTags(['cat', 'dog']); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 20c82f95..616277aa 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -16,3 +16,5 @@ \OCP\Server::get(\OC\Files\Node\HookConnector::class)->viewToNode(); // Do not clear hooks, we need them for our tests! // OC_Hook::clear(); + +require_once __DIR__ . '/AuditLog/AuditLogTestHelper.php';