diff --git a/ci/phpunit/TestBase.php b/ci/phpunit/TestBase.php index ec24393ad..0baf738aa 100644 --- a/ci/phpunit/TestBase.php +++ b/ci/phpunit/TestBase.php @@ -13,14 +13,23 @@ use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\models\CrackerBinaryType; use Hashtopolis\dba\models\File; +use Hashtopolis\dba\models\FileDownload; use Hashtopolis\dba\models\FileTask; use Hashtopolis\dba\models\Hashlist; use Hashtopolis\dba\models\HashType; +use Hashtopolis\dba\models\HealthCheck; +use Hashtopolis\dba\models\HealthCheckAgent; +use Hashtopolis\dba\models\JwtApiKey; use Hashtopolis\dba\models\RightGroup; use Hashtopolis\dba\models\Task; use Hashtopolis\dba\models\TaskWrapper; use Hashtopolis\dba\models\User; use Hashtopolis\dba\models\UserFactory; +use Hashtopolis\inc\defines\DHealthCheckAgentStatus; +use Hashtopolis\inc\defines\DHealthCheckMode; +use Hashtopolis\inc\defines\DHealthCheckStatus; +use Hashtopolis\inc\defines\DHealthCheckType; +use Hashtopolis\inc\defines\DFileDownloadStatus; use Hashtopolis\inc\defines\DHashlistFormat; use Hashtopolis\inc\defines\DTaskTypes; use Hashtopolis\inc\utils\UserUtils; @@ -157,19 +166,46 @@ protected function createCrackerBinary(CrackerBinaryType $crackerBinaryType): Cr return $crackerBinary; } - protected function createTask(TaskWrapper $taskWrapper, CrackerBinary $crackerBinary, CrackerBinaryType $crackerBinaryType): Task { + protected function createTask(TaskWrapper $taskWrapper, CrackerBinary $crackerBinary, CrackerBinaryType $crackerBinaryType, ?int $usePreprocessor = null, string $preprocessorCommand = ''): Task { $task = $this->createDatabaseObject( Factory::getTaskFactory(), - new Task(null, 'task_' . uniqid(), '--attack-mode 0', 60, 30, 0, 0, 1, 1, '#ffffff', 0, 0, 0, 0, $crackerBinary->getId(), $crackerBinaryType->getId(), $taskWrapper->getId(), 0, '', 0, 0, 0, 0, '') + new Task(null, 'task_' . uniqid(), '--attack-mode 0', 60, 30, 0, 0, 1, 1, '#ffffff', 0, 0, 0, 0, $crackerBinary->getId(), $crackerBinaryType->getId(), $taskWrapper->getId(), 0, '', 0, 0, 0, $usePreprocessor ?? 0, $preprocessorCommand) ); $this->assertTrue($task instanceof Task); return $task; } - protected function createFile(AccessGroup $group, int $isSecret = 0): File { + protected function createJwtApiKey(User $user, ?int $startValid = null, ?int $endValid = null, int $isRevoked = 0): JwtApiKey { + $key = $this->createDatabaseObject( + Factory::getJwtApiKeyFactory(), + new JwtApiKey(null, $startValid ?? time(), $endValid ?? time() + 3600, $user->getId(), $isRevoked) + ); + $this->assertTrue($key instanceof JwtApiKey); + return $key; + } + + protected function createHealthCheck(CrackerBinary $crackerBinary, int $status = DHealthCheckStatus::PENDING, int $checkType = DHealthCheckType::BRUTE_FORCE, int $hashtypeId = DHealthCheckMode::MD5, int $expectedCracks = 0, string $attackCmd = ''): HealthCheck { + $check = $this->createDatabaseObject( + Factory::getHealthCheckFactory(), + new HealthCheck(null, time(), $status, $checkType, $hashtypeId, $crackerBinary->getId(), $expectedCracks, $attackCmd) + ); + $this->assertTrue($check instanceof HealthCheck); + return $check; + } + + protected function createHealthCheckAgent(HealthCheck $healthCheck, Agent $agent, int $status = DHealthCheckAgentStatus::PENDING, int $cracked = 0, int $numGpus = 0, int $start = 0, int $end = 0, string $errors = ''): HealthCheckAgent { + $agentCheck = $this->createDatabaseObject( + Factory::getHealthCheckAgentFactory(), + new HealthCheckAgent(null, $healthCheck->getId(), $agent->getId(), $status, $cracked, $numGpus, $start, $end, $errors) + ); + $this->assertTrue($agentCheck instanceof HealthCheckAgent); + return $agentCheck; + } + + protected function createFile(AccessGroup $group, int $isSecret = 0, ?string $filename = null, int $size = 0, int $fileType = 0, int $lineCount = 0): File { $file = $this->createDatabaseObject( Factory::getFileFactory(), - new File(null, 'file_' . uniqid(), 0, $isSecret, 0, $group->getId(), 0) + new File(null, $filename ?? 'file_' . uniqid(), $size, $isSecret, $fileType, $group->getId(), $lineCount) ); $this->assertTrue($file instanceof File); return $file; @@ -183,6 +219,15 @@ protected function createFileTask(File $file, Task $task): FileTask { $this->assertTrue($fileTask instanceof FileTask); return $fileTask; } + + protected function createFileDownload(int $fileId, int $status = DFileDownloadStatus::PENDING): FileDownload { + $fileDownload = $this->createDatabaseObject( + Factory::getFileDownloadFactory(), + new FileDownload(null, time(), $fileId, $status) + ); + $this->assertTrue($fileDownload instanceof FileDownload); + return $fileDownload; + } protected function createAgent(string $prefix, int $isTrusted = 1): Agent { $suffix = uniqid(); diff --git a/ci/phpunit/inc/utils/FileDownloadUtilsTest.php b/ci/phpunit/inc/utils/FileDownloadUtilsTest.php new file mode 100644 index 000000000..13cf156ac --- /dev/null +++ b/ci/phpunit/inc/utils/FileDownloadUtilsTest.php @@ -0,0 +1,79 @@ +createAccessGroup('fdl_group'); + $this->file = $this->createFile($group); + $this->fileDownload = $this->createFileDownload($this->file->getId(), DFileDownloadStatus::DONE); + } + + public function testAddDownloadCreatesPendingDownload(): void { + $group = $this->createAccessGroup('fdl_new'); + $newFile = $this->createFile($group); + + FileDownloadUtils::addDownload($newFile->getId()); + + $qF1 = new QueryFilter(FileDownload::FILE_ID, $newFile->getId(), '='); + $qF2 = new QueryFilter(FileDownload::STATUS, DFileDownloadStatus::PENDING, '='); + $result = Factory::getFileDownloadFactory()->filter([Factory::FILTER => [$qF1, $qF2]], true); + + $this->assertInstanceOf(FileDownload::class, $result); + $this->assertSame($newFile->getId(), $result->getFileId()); + $this->assertSame(DFileDownloadStatus::PENDING, $result->getStatus()); + $this->registerDatabaseObject(Factory::getFileDownloadFactory(), $result); + } + + public function testAddDownloadSkipsExistingPending(): void { + $this->createFileDownload($this->file->getId()); + + FileDownloadUtils::addDownload($this->file->getId()); + + $qF1 = new QueryFilter(FileDownload::FILE_ID, $this->file->getId(), '='); + $qF2 = new QueryFilter(FileDownload::STATUS, DFileDownloadStatus::PENDING, '='); + $pending = Factory::getFileDownloadFactory()->filter([Factory::FILTER => [$qF1, $qF2]]); + $this->assertCount(1, $pending); + } + + public function testAddDownloadCreatesNewForCompletedFile(): void { + FileDownloadUtils::addDownload($this->file->getId()); + + $qF1 = new QueryFilter(FileDownload::FILE_ID, $this->file->getId(), '='); + $qF2 = new QueryFilter(FileDownload::STATUS, DFileDownloadStatus::PENDING, '='); + $pending = Factory::getFileDownloadFactory()->filter([Factory::FILTER => [$qF1, $qF2]], true); + + $this->assertInstanceOf(FileDownload::class, $pending); + $this->assertSame($this->file->getId(), $pending->getFileId()); + $this->assertSame(DFileDownloadStatus::PENDING, $pending->getStatus()); + $this->registerDatabaseObject(Factory::getFileDownloadFactory(), $pending); + } + + public function testRemoveFileDeletesDownloads(): void { + FileDownloadUtils::removeFile($this->fileDownload->getFileId()); + + $qF = new QueryFilter(FileDownload::FILE_ID, $this->fileDownload->getFileId(), '='); + $remaining = Factory::getFileDownloadFactory()->filter([Factory::FILTER => $qF]); + $this->assertSame([], $remaining); + } + + public function testRemoveFileIsNoopForNonExistent(): void { + FileDownloadUtils::removeFile(-1); + } +} diff --git a/ci/phpunit/inc/utils/FileUtilsTest.php b/ci/phpunit/inc/utils/FileUtilsTest.php new file mode 100644 index 000000000..2c480090d --- /dev/null +++ b/ci/phpunit/inc/utils/FileUtilsTest.php @@ -0,0 +1,146 @@ +user = $this->createUser('fu_user'); + $this->group = $this->createAccessGroup('fu_group'); + $this->createAccessGroupUser($this->user, $this->group); + + $this->file = $this->createFile($this->group); + $this->ruleFile = $this->createFile($this->group, 0, 'test_rule_' . uniqid() . '.rule', 512, DFileType::RULE); + $this->wordlistFile = $this->createFile($this->group); + $this->otherFile = $this->createFile($this->group, 0, 'test_other_' . uniqid() . '.bin', 256, DFileType::OTHER); + } + + public function testGetFileReturnsFileForAuthorizedUser(): void { + $result = FileUtils::getFile($this->file->getId(), $this->user); + $this->assertInstanceOf(File::class, $result); + $this->assertSame($this->file->getId(), $result->getId()); + } + + public function testGetFileThrowsForInvalidId(): void { + $this->expectException(HTException::class); + FileUtils::getFile(-1, $this->user); + } + + public function testGetFileThrowsForUnauthorizedUser(): void { + $otherGroup = $this->createAccessGroup('fu_other'); + $otherFile = $this->createFile($otherGroup); + + $this->expectException(HTException::class); + FileUtils::getFile($otherFile->getId(), $this->user); + } + + public function testSetFileTypeUpdatesType(): void { + FileUtils::setFileType($this->file->getId(), DFileType::RULE, $this->user); + + $updated = Factory::getFileFactory()->get($this->file->getId()); + $this->assertSame(DFileType::RULE, $updated->getFileType()); + } + + public function testSetFileTypeThrowsForInvalidType(): void { + $this->expectException(HTException::class); + FileUtils::setFileType($this->file->getId(), 999, $this->user); + } + + public function testSwitchSecretTogglesSecret(): void { + FileUtils::switchSecret($this->file->getId(), 1, $this->user); + + $updated = Factory::getFileFactory()->get($this->file->getId()); + $this->assertSame(1, $updated->getIsSecret()); + + FileUtils::switchSecret($this->file->getId(), 0, $this->user); + + $updated = Factory::getFileFactory()->get($this->file->getId()); + $this->assertSame(0, $updated->getIsSecret()); + } + + public function testGetFilesReturnsFilesInUserAccessGroups(): void { + $files = FileUtils::getFiles($this->user); + $fileIds = array_map(fn(File $f) => $f->getId(), $files); + + $this->assertContains($this->file->getId(), $fileIds); + $this->assertContains($this->ruleFile->getId(), $fileIds); + $this->assertContains($this->wordlistFile->getId(), $fileIds); + $this->assertContains($this->otherFile->getId(), $fileIds); + } + + public function testGetFilesExcludesTemporaryFiles(): void { + $tempFile = $this->createFile($this->group, 0, 'temp_' . uniqid() . '.tmp', 0, DFileType::TEMPORARY); + + $files = FileUtils::getFiles($this->user); + $fileIds = array_map(fn(File $f) => $f->getId(), $files); + + $this->assertNotContains($tempFile->getId(), $fileIds); + } + + public function testLoadFilesByCategoryCategorizesFiles(): void { + [$rules, $wordlists, $other] = FileUtils::loadFilesByCategory($this->user, []); + + $ruleIds = array_map(fn($set) => $set->getAllValues()['file']->getId(), $rules); + $wlIds = array_map(fn($set) => $set->getAllValues()['file']->getId(), $wordlists); + $otherIds = array_map(fn($set) => $set->getAllValues()['file']->getId(), $other); + + $this->assertContains($this->ruleFile->getId(), $ruleIds); + $this->assertContains($this->file->getId(), $wlIds); + $this->assertContains($this->wordlistFile->getId(), $wlIds); + $this->assertContains($this->otherFile->getId(), $otherIds); + } + + public function testLoadFilesByCategoryMarksCheckedFiles(): void { + [$rules, $wordlists, $other] = FileUtils::loadFilesByCategory($this->user, [$this->file->getId()]); + + $checkedIds = []; + foreach (array_merge($rules, $wordlists, $other) as $set) { + $data = $set->getAllValues(); + if ($data['checked'] === '1') { + $checkedIds[] = $data['file']->getId(); + } + } + + $this->assertContains($this->file->getId(), $checkedIds); + } + + public function testDeleteThrowsForInvalidId(): void { + $this->expectException(HTException::class); + FileUtils::delete(-1, $this->user); + } + + public function testDeleteThrowsWhenFileInUseByTask(): void { + $this->expectException(HTException::class); + + $hashType = $this->createHashType(); + $hashlist = $this->createHashlist($this->group, $hashType); + $crackerBinaryType = $this->createCrackerBinaryType(); + $crackerBinary = $this->createCrackerBinary($crackerBinaryType); + $taskWrapper = $this->createTaskWrapper($this->group, $hashlist); + $task = $this->createTask($taskWrapper, $crackerBinary, $crackerBinaryType); + $this->createFileTask($this->file, $task); + + FileUtils::delete($this->file->getId(), $this->user); + } +} diff --git a/ci/phpunit/inc/utils/HashtypeUtilsTest.php b/ci/phpunit/inc/utils/HashtypeUtilsTest.php new file mode 100644 index 000000000..6f07e6c24 --- /dev/null +++ b/ci/phpunit/inc/utils/HashtypeUtilsTest.php @@ -0,0 +1,74 @@ +user = $this->createUser('ht_user'); + } + + public function testAddHashtypeCreatesNewHashtype(): void { + $hashtypeId = 999001; + $description = 'test_hashtype_' . uniqid(); + + $hashtype = HashtypeUtils::addHashtype($hashtypeId, $description, 0, false, $this->user); + + $this->assertSame($hashtypeId, $hashtype->getId()); + $this->assertStringContainsString($description, $hashtype->getDescription()); + + Factory::getHashTypeFactory()->delete($hashtype); + } + + public function testAddHashtypeThrowsForDuplicateId(): void { + $existing = $this->createHashType(); + + $this->expectException(HttpError::class); + HashtypeUtils::addHashtype($existing->getId(), 'new_desc', 0, false, $this->user); + } + + public function testAddHashtypeThrowsForEmptyDescription(): void { + $this->expectException(HttpError::class); + HashtypeUtils::addHashtype(999003, '', 0, false, $this->user); + } + + public function testAddHashtypeThrowsForNegativeId(): void { + $this->expectException(HttpError::class); + HashtypeUtils::addHashtype(-1, 'desc', 0, false, $this->user); + } + + public function testDeleteHashtypeRemovesHashtype(): void { + $hashtype = $this->createHashType(); + + HashtypeUtils::deleteHashtype($hashtype->getId()); + + $this->assertNull(Factory::getHashTypeFactory()->get($hashtype->getId())); + } + + public function testDeleteHashtypeThrowsForInvalidId(): void { + $this->expectException(HTException::class); + HashtypeUtils::deleteHashtype(-1); + } + + public function testDeleteHashtypeThrowsWhenHashlistsExist(): void { + $hashtype = $this->createHashType(); + $accessGroup = $this->createAccessGroup('ht_del'); + $this->createHashlist($accessGroup, $hashtype); + + $this->expectException(HTException::class); + HashtypeUtils::deleteHashtype($hashtype->getId()); + } +} diff --git a/ci/phpunit/inc/utils/HealthUtilsTest.php b/ci/phpunit/inc/utils/HealthUtilsTest.php new file mode 100644 index 000000000..589d93cda --- /dev/null +++ b/ci/phpunit/inc/utils/HealthUtilsTest.php @@ -0,0 +1,212 @@ +createCrackerBinaryType(); + $this->crackerBinary = $this->createCrackerBinary($crackerBinaryType); + $this->agent = $this->createAgent('hc_agent'); + $this->otherAgent = $this->createAgent('hc_other'); + + $this->healthCheck = $this->createHealthCheck($this->crackerBinary, DHealthCheckStatus::PENDING, DHealthCheckType::BRUTE_FORCE, DHealthCheckMode::MD5, 50, '-a 3 -1 ?l?u?d ?1?1?1?1?1'); + + $this->healthCheckAgent = $this->createHealthCheckAgent($this->healthCheck, $this->agent); + + $this->completedAgent = $this->createHealthCheckAgent($this->healthCheck, $this->otherAgent, DHealthCheckAgentStatus::COMPLETED, 10, 2, 100, 200); + } + + #[Override] + protected function tearDown(): void { + $tmpFile = '/tmp/health-check-' . ($this->healthCheck->getId() ?? 0) . '.txt'; + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + parent::tearDown(); + } + + public function testGenerateHashMd5(): void { + $plain = 'testplain'; + $hash = HealthUtils::generateHash(DHealthCheckMode::MD5, $plain); + $this->assertSame(md5($plain), $hash); + } + + public function testGenerateHashBcrypt(): void { + $plain = 'abc'; + $hash = HealthUtils::generateHash(DHealthCheckMode::BCRYPT, $plain); + $this->assertNotFalse(password_verify($plain, $hash)); + } + + public function testGenerateHashThrowsForUnknownHashtype(): void { + $this->expectException(HTException::class); + HealthUtils::generateHash(999999, 'plain'); + } + + public function testGetAttackModeBruteForce(): void { + $mode = $this->callPrivateMethod('getAttackMode', DHealthCheckType::BRUTE_FORCE); + $this->assertSame(' -a 3', $mode); + } + + public function testGetAttackInputMd5BruteForce(): void { + $input = $this->callPrivateMethod('getAttackInput', DHealthCheckMode::MD5, DHealthCheckType::BRUTE_FORCE); + $this->assertSame(' -1 ?l?u?d ?1?1?1?1?1', $input); + } + + public function testGetAttackInputBcryptBruteForce(): void { + $input = $this->callPrivateMethod('getAttackInput', DHealthCheckMode::BCRYPT, DHealthCheckType::BRUTE_FORCE); + $this->assertSame(' ?l?l?l', $input); + } + + public function testGetAttackNumHashesMd5(): void { + $num = $this->callPrivateMethod('getAttackNumHashes', DHealthCheckMode::MD5); + $this->assertSame(100, $num); + } + + public function testGetAttackNumHashesBcrypt(): void { + $num = $this->callPrivateMethod('getAttackNumHashes', DHealthCheckMode::BCRYPT); + $this->assertSame(10, $num); + } + + public function testGetAttackNumHashesUnknown(): void { + $num = $this->callPrivateMethod('getAttackNumHashes', 999); + $this->assertSame(100, $num); + } + + public function testCheckNeededReturnsPendingAgentCheck(): void { + $result = HealthUtils::checkNeeded($this->agent); + $this->assertInstanceOf(HealthCheckAgent::class, $result); + $this->assertSame($this->healthCheckAgent->getId(), $result->getId()); + } + + public function testCheckNeededReturnsFalseWhenAgentHasNoPending(): void { + $freshAgent = $this->createAgent('hc_fresh'); + $result = HealthUtils::checkNeeded($freshAgent); + $this->assertFalse($result); + } + + public function testCheckNeededReturnsFalseWhenHealthCheckIsAborted(): void { + $abortedCheck = $this->createHealthCheck($this->crackerBinary, DHealthCheckStatus::ABORTED); + $isolatedAgent = $this->createAgent('hc_isolated'); + $pendingAgent = $this->createHealthCheckAgent($abortedCheck, $isolatedAgent); + + $result = HealthUtils::checkNeeded($isolatedAgent); + $this->assertFalse($result); + } + + public function testCheckCompletionMarksCompleteWhenAllAgentsDone(): void { + $allDoneCheck = $this->createHealthCheck($this->crackerBinary); + $this->createHealthCheckAgent($allDoneCheck, $this->agent, DHealthCheckAgentStatus::COMPLETED, 5, 1, 0, 10); + $this->createHealthCheckAgent($allDoneCheck, $this->otherAgent, DHealthCheckAgentStatus::FAILED, 0, 0, 0, 0, 'error'); + + HealthUtils::checkCompletion($allDoneCheck); + + $updated = Factory::getHealthCheckFactory()->get($allDoneCheck->getId()); + $this->assertSame(DHealthCheckStatus::COMPLETED, $updated->getStatus()); + } + + public function testCheckCompletionDoesNotCompleteWhenAgentPending(): void { + HealthUtils::checkCompletion($this->healthCheck); + + $updated = Factory::getHealthCheckFactory()->get($this->healthCheck->getId()); + $this->assertSame(DHealthCheckStatus::PENDING, $updated->getStatus()); + } + + public function testResetAgentCheckResetsPendingAgent(): void { + HealthUtils::resetAgentCheck($this->healthCheckAgent->getId()); + + $updated = Factory::getHealthCheckAgentFactory()->get($this->healthCheckAgent->getId()); + $this->assertSame(DHealthCheckAgentStatus::PENDING, $updated->getStatus()); + $this->assertSame(0, $updated->getStart()); + $this->assertSame(0, $updated->getEnd()); + $this->assertSame('', $updated->getErrors()); + $this->assertSame(0, $updated->getCracked()); + $this->assertSame(0, $updated->getNumGpus()); + } + + public function testResetAgentCheckResetsCompletedAgentUnderPendingCheck(): void { + HealthUtils::resetAgentCheck($this->completedAgent->getId()); + + $updated = Factory::getHealthCheckAgentFactory()->get($this->completedAgent->getId()); + $this->assertSame(DHealthCheckAgentStatus::PENDING, $updated->getStatus()); + $this->assertSame(0, $updated->getCracked()); + $this->assertSame(0, $updated->getNumGpus()); + $this->assertSame(0, $updated->getStart()); + $this->assertSame(0, $updated->getEnd()); + $this->assertSame('', $updated->getErrors()); + + $parentCheck = Factory::getHealthCheckFactory()->get($this->healthCheck->getId()); + $this->assertSame(DHealthCheckStatus::PENDING, $parentCheck->getStatus()); + } + + public function testResetAgentCheckReopensCompletedHealthCheck(): void { + $completedCheck = $this->createHealthCheck($this->crackerBinary, DHealthCheckStatus::COMPLETED); + $agentCheck = $this->createHealthCheckAgent($completedCheck, $this->agent, DHealthCheckAgentStatus::COMPLETED, 5, 1, 0, 10); + + HealthUtils::resetAgentCheck($agentCheck->getId()); + + $updatedCheck = Factory::getHealthCheckFactory()->get($completedCheck->getId()); + $this->assertSame(DHealthCheckStatus::PENDING, $updatedCheck->getStatus()); + } + + public function testResetAgentCheckThrowsForAbortedHealthCheck(): void { + $abortedCheck = $this->createHealthCheck($this->crackerBinary, DHealthCheckStatus::ABORTED); + $agentCheck = $this->createHealthCheckAgent($abortedCheck, $this->agent, DHealthCheckAgentStatus::FAILED, 5, 1, 0, 10); + + $this->expectException(HTException::class); + HealthUtils::resetAgentCheck($agentCheck->getId()); + } + + public function testResetAgentCheckThrowsForInvalidId(): void { + $this->expectException(HTException::class); + HealthUtils::resetAgentCheck(-1); + } + + public function testDeleteHealthCheckRemovesCheckAndAgents(): void { + HealthUtils::deleteHealthCheck($this->healthCheck->getId()); + + $this->assertNull(Factory::getHealthCheckFactory()->get($this->healthCheck->getId())); + + $qF = new QueryFilter(HealthCheckAgent::HEALTH_CHECK_ID, $this->healthCheck->getId(), '='); + $remaining = Factory::getHealthCheckAgentFactory()->filter([Factory::FILTER => $qF]); + $this->assertSame([], $remaining); + } + + public function testDeleteHealthCheckThrowsForInvalidId(): void { + $this->expectException(HTException::class); + HealthUtils::deleteHealthCheck(-1); + } + + private function callPrivateMethod(string $name, ...$args): mixed { + $ref = new ReflectionClass(HealthUtils::class); + $method = $ref->getMethod($name); + return $method->invoke(null, ...$args); + } +} diff --git a/ci/phpunit/inc/utils/JwtTokenUtilsTest.php b/ci/phpunit/inc/utils/JwtTokenUtilsTest.php new file mode 100644 index 000000000..cdd0201bc --- /dev/null +++ b/ci/phpunit/inc/utils/JwtTokenUtilsTest.php @@ -0,0 +1,60 @@ +user = $this->createUser('jwt_user'); + } + + public function testCreateKeyCreatesValidKey(): void { + $start = time(); + $end = $start + 3600; + + $key = JwtTokenUtils::createKey($this->user->getId(), $start, $end); + + $this->assertInstanceOf(JwtApiKey::class, $key); + $this->assertSame($start, $key->getStartValid()); + $this->assertSame($end, $key->getEndValid()); + $this->assertSame($this->user->getId(), $key->getUserId()); + $this->assertNotNull($key->getId()); + $this->registerDatabaseObject(Factory::getJwtApiKeyFactory(), $key); + } + + public function testCreateKeyThrowsForInvalidUser(): void { + $this->expectException(HttpError::class); + JwtTokenUtils::createKey(-1, time(), time() + 3600); + } + + public function testDeleteKeyDeletesExpiredKey(): void { + $start = time() - 7200; + $end = time() - 3600; + $key = $this->createJwtApiKey($this->user, $start, $end); + + JwtTokenUtils::deleteKey($key); + + $this->assertNull(Factory::getJwtApiKeyFactory()->get($key->getId())); + } + + public function testDeleteKeyThrowsForUnexpiredKey(): void { + $key = $this->createJwtApiKey($this->user); + + $this->expectException(HttpForbidden::class); + JwtTokenUtils::deleteKey($key); + } +} diff --git a/ci/phpunit/inc/utils/LockUtilsTest.php b/ci/phpunit/inc/utils/LockUtilsTest.php new file mode 100644 index 000000000..5aa4b04ac --- /dev/null +++ b/ci/phpunit/inc/utils/LockUtilsTest.php @@ -0,0 +1,107 @@ +releaseTestLock(); + $this->cleanupLockFiles(); + $this->lockFile = self::LOCK_DIR . '/' . self::TEST_LOCK; + } + + #[Override] + protected function tearDown(): void { + $this->releaseTestLock(); + $this->cleanupLockFiles(); + parent::tearDown(); + } + + private function releaseTestLock(): void { + LockUtils::release(self::TEST_LOCK); + } + + private function cleanupLockFiles(): void { + $prefixes = [Lock::CHUNKING, self::TEST_LOCK]; + foreach ($prefixes as $prefix) { + $path = self::LOCK_DIR . '/' . $prefix; + if (is_file($path)) { + unlink($path); + } + } + } + + public function testGetCreatesAndAcquiresLock(): void { + LockUtils::get(self::TEST_LOCK); + $this->assertFileExists($this->lockFile); + LockUtils::release(self::TEST_LOCK); + } + + public function testGetReturnsCachedInstance(): void { + LockUtils::get(self::TEST_LOCK); + LockUtils::get(self::TEST_LOCK); + LockUtils::release(self::TEST_LOCK); + $this->assertFileDoesNotExist($this->lockFile); + } + + public function testReleaseReleasesLockForReacquisition(): void { + LockUtils::get(self::TEST_LOCK); + LockUtils::release(self::TEST_LOCK); + + LockUtils::get(self::TEST_LOCK); + LockUtils::release(self::TEST_LOCK); + $this->assertFileDoesNotExist($this->lockFile); + } + + public function testReleaseIsNoopForUnknownLock(): void { + LockUtils::release('nonexistent.lock'); + $this->assertFileDoesNotExist(self::LOCK_DIR . '/nonexistent.lock'); + } + + public function testDeleteLockFileRemovesExistingLockFile(): void { + $taskId = 999001; + $lockFilePath = self::LOCK_DIR . '/' . Lock::CHUNKING . $taskId; + + touch($lockFilePath); + $this->assertFileExists($lockFilePath); + + LockUtils::deleteLockFile($taskId); + + $this->assertFileDoesNotExist($lockFilePath); + } + + public function testDeleteLockFileDoesNotThrowForMissingFile(): void { + $taskId = 999002; + LockUtils::deleteLockFile($taskId); + $lockFilePath = self::LOCK_DIR . '/' . Lock::CHUNKING . $taskId; + $this->assertFileDoesNotExist($lockFilePath); + } + + public function testDeleteLockFileCleansUpOnlySpecifiedTask(): void { + $taskIdA = 999003; + $taskIdB = 999004; + $pathA = self::LOCK_DIR . '/' . Lock::CHUNKING . $taskIdA; + $pathB = self::LOCK_DIR . '/' . Lock::CHUNKING . $taskIdB; + + touch($pathA); + touch($pathB); + + LockUtils::deleteLockFile($taskIdA); + + $this->assertFileDoesNotExist($pathA); + $this->assertFileExists($pathB); + + unlink($pathB); + } +} diff --git a/ci/phpunit/inc/utils/PreprocessorUtilsTest.php b/ci/phpunit/inc/utils/PreprocessorUtilsTest.php new file mode 100644 index 000000000..48de0df46 --- /dev/null +++ b/ci/phpunit/inc/utils/PreprocessorUtilsTest.php @@ -0,0 +1,358 @@ +preprocessor = PreprocessorUtils::addPreprocessor( + 'test_pp_' . uniqid(), + 'test_binary_' . uniqid(), + 'https://example.com/test.zip', + '--keyspace', + '--skip', + '--limit' + ); + } + + #[Override] + protected function tearDown(): void { + try { + PreprocessorUtils::delete($this->preprocessor->getId()); + } + catch (Exception) { + } + parent::tearDown(); + } + + private function createPreprocessor(string $suffix = ''): Preprocessor { + $suffix = $suffix ?: uniqid(); + $pp = PreprocessorUtils::addPreprocessor( + 'tmp_pp_' . $suffix, + 'tmp_binary_' . $suffix, + 'https://example.com/' . $suffix . '.zip', + '--ks', + '--sk', + '--lm' + ); + $this->registerDatabaseObject(Factory::getPreprocessorFactory(), $pp); + return $pp; + } + + public function testAddPreprocessorCreatesWithValidData(): void { + $name = 'add_create_' . uniqid(); + $binaryName = 'add_binary_' . uniqid(); + $url = 'https://example.com/add_create.zip'; + + $pp = PreprocessorUtils::addPreprocessor($name, $binaryName, $url, '--keyspace', '--skip', '--limit'); + $this->registerDatabaseObject(Factory::getPreprocessorFactory(), $pp); + + $this->assertInstanceOf(Preprocessor::class, $pp); + $this->assertSame($name, $pp->getName()); + $this->assertSame($binaryName, $pp->getBinaryName()); + $this->assertSame($url, $pp->getUrl()); + $this->assertSame('--keyspace', $pp->getKeyspaceCommand()); + $this->assertSame('--skip', $pp->getSkipCommand()); + $this->assertSame('--limit', $pp->getLimitCommand()); + $this->assertNotNull($pp->getId()); + } + + public function testAddPreprocessorConvertsEmptyCommandsToNull(): void { + $pp = PreprocessorUtils::addPreprocessor( + 'add_null_cmds_' . uniqid(), + 'binary_null', + 'https://example.com/null.zip', + '', '', '' + ); + $this->registerDatabaseObject(Factory::getPreprocessorFactory(), $pp); + + $this->assertNull($pp->getKeyspaceCommand()); + $this->assertNull($pp->getSkipCommand()); + $this->assertNull($pp->getLimitCommand()); + } + + public function testAddPreprocessorThrowsForDuplicateName(): void { + $this->expectException(HttpConflict::class); + PreprocessorUtils::addPreprocessor( + $this->preprocessor->getName(), + 'binary_dup', + 'https://example.com/dup.zip', + '', '', '' + ); + } + + public function testAddPreprocessorThrowsForEmptyName(): void { + $this->expectException(HttpError::class); + PreprocessorUtils::addPreprocessor('', 'binary', 'https://example.com/e.zip', '', '', ''); + } + + public function testAddPreprocessorThrowsForEmptyBinaryName(): void { + $this->expectException(HttpError::class); + PreprocessorUtils::addPreprocessor('name', '', 'https://example.com/e.zip', '', '', ''); + } + + public function testAddPreprocessorThrowsForEmptyUrl(): void { + $this->expectException(HttpError::class); + PreprocessorUtils::addPreprocessor('name', 'binary', '', '', '', ''); + } + + public function testAddPreprocessorThrowsForBlacklistedBinaryName(): void { + $this->expectException(HttpError::class); + PreprocessorUtils::addPreprocessor('name', 'bad|binary', 'https://example.com/b.zip', '', '', ''); + } + + public function testAddPreprocessorThrowsForBlacklistedKeyspace(): void { + $this->expectException(HttpError::class); + PreprocessorUtils::addPreprocessor( + 'name', 'binary', 'https://example.com/b.zip', + '--keyspace;rm', '--skip', '--limit' + ); + } + + public function testAddPreprocessorThrowsForBlacklistedSkip(): void { + $this->expectException(HttpError::class); + PreprocessorUtils::addPreprocessor( + 'name', 'binary', 'https://example.com/b.zip', + '--keyspace', '--skip$test', '--limit' + ); + } + + public function testAddPreprocessorThrowsForBlacklistedLimit(): void { + $this->expectException(HttpError::class); + PreprocessorUtils::addPreprocessor( + 'name', 'binary', 'https://example.com/b.zip', + '--keyspace', '--skip', '--limit&test&' + ); + } + + public function testGetPreprocessorReturnsPreprocessor(): void { + $retrieved = PreprocessorUtils::getPreprocessor($this->preprocessor->getId()); + $this->assertInstanceOf(Preprocessor::class, $retrieved); + $this->assertSame($this->preprocessor->getId(), $retrieved->getId()); + } + + public function testGetPreprocessorThrowsForInvalidId(): void { + $this->expectException(HTException::class); + PreprocessorUtils::getPreprocessor(-1); + } + + public function testDeleteRemovesPreprocessor(): void { + $pp = $this->createPreprocessor('del_test'); + $ppId = $pp->getId(); + + PreprocessorUtils::delete($ppId); + + $this->assertNull(Factory::getPreprocessorFactory()->get($ppId)); + } + + public function testDeleteThrowsForNonExistentPreprocessor(): void { + $this->expectException(HTException::class); + PreprocessorUtils::delete(-1); + } + + public function testDeleteThrowsWhenTaskUsesPreprocessor(): void { + $pp = $this->createPreprocessor('del_task'); + $hashType = $this->createHashType(); + $hashlist = $this->createHashlist($this->createAccessGroup('del_pp'), $hashType); + $crackerBinaryType = $this->createCrackerBinaryType(); + $crackerBinary = $this->createCrackerBinary($crackerBinaryType); + $taskWrapper = $this->createTaskWrapper($this->createAccessGroup('del_pp'), $hashlist); + $this->createDatabaseObject( + Factory::getTaskFactory(), + new Task( + null, 'task_with_pp_' . uniqid(), '--attack-mode 0', 60, 30, 0, 0, 1, 1, + '#ffffff', 0, 0, 0, 0, $crackerBinary->getId(), $crackerBinaryType->getId(), + $taskWrapper->getId(), 0, '', 0, 0, 0, $pp->getId(), '' + ) + ); + + $this->expectException(HttpError::class); + PreprocessorUtils::delete($pp->getId()); + } + + public function testEditNameUpdatesName(): void { + $newName = 'rename_pp_' . uniqid(); + PreprocessorUtils::editName($this->preprocessor->getId(), $newName); + + $updated = Factory::getPreprocessorFactory()->get($this->preprocessor->getId()); + $this->assertSame($newName, $updated->getName()); + } + + public function testEditNameThrowsForDuplicateName(): void { + $other = $this->createPreprocessor('rename_dup'); + + $this->expectException(HTException::class); + PreprocessorUtils::editName($this->preprocessor->getId(), $other->getName()); + } + + public function testEditBinaryNameUpdates(): void { + $newBinary = 'new_binary_' . uniqid(); + PreprocessorUtils::editBinaryName($this->preprocessor->getId(), $newBinary); + + $updated = Factory::getPreprocessorFactory()->get($this->preprocessor->getId()); + $this->assertSame($newBinary, $updated->getBinaryName()); + } + + public function testEditBinaryNameThrowsForEmpty(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editBinaryName($this->preprocessor->getId(), ''); + } + + public function testEditBinaryNameThrowsForBlacklistedChars(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editBinaryName($this->preprocessor->getId(), 'bad|binary'); + } + + public function testEditKeyspaceCommandUpdates(): void { + $newCmd = '--new-keyspace'; + PreprocessorUtils::editKeyspaceCommand($this->preprocessor->getId(), $newCmd); + + $updated = Factory::getPreprocessorFactory()->get($this->preprocessor->getId()); + $this->assertSame($newCmd, $updated->getKeyspaceCommand()); + } + + public function testEditKeyspaceCommandThrowsForBlacklistedChars(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editKeyspaceCommand($this->preprocessor->getId(), 'keyspace;rm'); + } + + public function testEditSkipCommandUpdates(): void { + $newCmd = '--new-skip'; + PreprocessorUtils::editSkipCommand($this->preprocessor->getId(), $newCmd); + + $updated = Factory::getPreprocessorFactory()->get($this->preprocessor->getId()); + $this->assertSame($newCmd, $updated->getSkipCommand()); + } + + public function testEditSkipCommandThrowsForBlacklistedChars(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editSkipCommand($this->preprocessor->getId(), 'skip$test'); + } + + public function testEditLimitCommandUpdates(): void { + $newCmd = '--new-limit'; + PreprocessorUtils::editLimitCommand($this->preprocessor->getId(), $newCmd); + + $updated = Factory::getPreprocessorFactory()->get($this->preprocessor->getId()); + $this->assertSame($newCmd, $updated->getLimitCommand()); + } + + public function testEditLimitCommandThrowsForBlacklistedChars(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editLimitCommand($this->preprocessor->getId(), 'limit&test&'); + } + + public function testEditPreprocessorUpdatesAllFields(): void { + $newName = 'full_edit_' . uniqid(); + $newBinary = 'full_bin_' . uniqid(); + $newUrl = 'https://example.com/full.zip'; + + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), $newName, $newBinary, $newUrl, + '--ks', '--sk', '--lm' + ); + + $updated = Factory::getPreprocessorFactory()->get($this->preprocessor->getId()); + $this->assertSame($newName, $updated->getName()); + $this->assertSame($newBinary, $updated->getBinaryName()); + $this->assertSame($newUrl, $updated->getUrl()); + $this->assertSame('--ks', $updated->getKeyspaceCommand()); + $this->assertSame('--sk', $updated->getSkipCommand()); + $this->assertSame('--lm', $updated->getLimitCommand()); + } + + public function testEditPreprocessorThrowsForDuplicateName(): void { + $other = $this->createPreprocessor('full_dup'); + + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), $other->getName(), + 'binary', 'https://example.com/f.zip', + '', '', '' + ); + } + + public function testEditPreprocessorThrowsForEmptyName(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), '', 'binary', 'https://example.com/f.zip', + '', '', '' + ); + } + + public function testEditPreprocessorThrowsForEmptyBinaryName(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), 'name' . uniqid(), '', 'https://example.com/f.zip', + '', '', '' + ); + } + + public function testEditPreprocessorThrowsForEmptyUrl(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), 'name' . uniqid(), 'binary', '', + '', '', '' + ); + } + + public function testEditPreprocessorThrowsForBlacklistedBinaryName(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), 'name' . uniqid(), 'bad|binary', 'https://example.com/f.zip', + '', '', '' + ); + } + + public function testEditPreprocessorThrowsForBlacklistedKeyspace(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), 'name' . uniqid(), 'binary', 'https://example.com/f.zip', + 'keyspace;rm', '', '' + ); + } + + public function testEditPreprocessorThrowsForBlacklistedSkip(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), 'name' . uniqid(), 'binary', 'https://example.com/f.zip', + '', 'skip$test', '' + ); + } + + public function testEditPreprocessorThrowsForBlacklistedLimit(): void { + $this->expectException(HTException::class); + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), 'name' . uniqid(), 'binary', 'https://example.com/f.zip', + '', '', 'limit`test`' + ); + } + + public function testEditPreprocessorConvertsEmptyCommandsToNull(): void { + PreprocessorUtils::editPreprocessor( + $this->preprocessor->getId(), 'name' . uniqid(), 'binary', 'https://example.com/f.zip', + '', '', '' + ); + + $updated = Factory::getPreprocessorFactory()->get($this->preprocessor->getId()); + $this->assertNull($updated->getKeyspaceCommand()); + $this->assertNull($updated->getSkipCommand()); + $this->assertNull($updated->getLimitCommand()); + } +} diff --git a/src/inc/Util.php b/src/inc/Util.php index c0850aca7..3449c2cd2 100755 --- a/src/inc/Util.php +++ b/src/inc/Util.php @@ -967,9 +967,10 @@ public static function escapeSpecial($string) { * @param $string string * @return bool true if at least one character is in the blacklist */ - public static function containsBlacklistedChars($string) { - for ($i = 0; $i < strlen(SConfig::getInstance()->getVal(DConfig::BLACKLIST_CHARS)); $i++) { - if (strpos($string, SConfig::getInstance()->getVal(DConfig::BLACKLIST_CHARS)[$i]) !== false) { + public static function containsBlacklistedChars(string $string): bool { + $blacklisted = SConfig::getInstance()->getVal(DConfig::BLACKLIST_CHARS); + for ($i = 0; $i < strlen($blacklisted); $i++) { + if (str_contains($string, $blacklisted[$i])) { return true; } } diff --git a/src/migrations/mysql/20260617130352_blacklist-chars-sync.sql b/src/migrations/mysql/20260617130352_blacklist-chars-sync.sql new file mode 100644 index 000000000..5b011d454 --- /dev/null +++ b/src/migrations/mysql/20260617130352_blacklist-chars-sync.sql @@ -0,0 +1 @@ +-- This migration is only a placeholder to keep migrations parallel \ No newline at end of file diff --git a/src/migrations/postgres/20260617130352_blacklist-chars-sync.sql b/src/migrations/postgres/20260617130352_blacklist-chars-sync.sql new file mode 100644 index 000000000..f94cdad5c --- /dev/null +++ b/src/migrations/postgres/20260617130352_blacklist-chars-sync.sql @@ -0,0 +1,6 @@ +-- the backtick as default blacklisted character got lost for postgres, so for the cases where people still have the default, we add it +UPDATE Config +SET value = '&|"''{}()[]$<>;`' +WHERE item = 'blacklistChars' + AND configSectionId=1 + AND value = '&|"''{}()[]$<>;'; \ No newline at end of file