diff --git a/config/module.config.php b/config/module.config.php index d2f4563f..926ead73 100755 --- a/config/module.config.php +++ b/config/module.config.php @@ -159,6 +159,7 @@ Service\ObjectCategoryService::class => AutowireFactory::class, Service\ObjectObjectService::class => AutowireFactory::class, Service\PasswordService::class => AutowireFactory::class, + Service\RiskSourceService::class => AutowireFactory::class, Service\ScaleService::class => AutowireFactory::class, Service\ScaleCommentService::class => AutowireFactory::class, Service\ScaleImpactTypeService::class => AutowireFactory::class, @@ -208,7 +209,9 @@ Table\OperationalRiskScaleCommentTable::class => Table\Factory\CoreEntityManagerFactory::class, Table\OperationalInstanceRiskScaleTable::class => Table\Factory\CoreEntityManagerFactory::class, Table\PasswordTokenTable::class => Table\Factory\ClientEntityManagerFactory::class, + Table\ReassessmentTriggerTable::class => Table\Factory\CoreEntityManagerFactory::class, Table\ReferentialTable::class => Table\Factory\CoreEntityManagerFactory::class, + Table\RiskSourceTable::class => Table\Factory\CoreEntityManagerFactory::class, Table\RolfTagTable::class => Table\Factory\CoreEntityManagerFactory::class, Table\RolfRiskTable::class => Table\Factory\CoreEntityManagerFactory::class, Table\ScaleTable::class => Table\Factory\CoreEntityManagerFactory::class, @@ -228,6 +231,7 @@ Service\ConnectedUserService::class => AutowireFactory::class, /* Translation */ Service\TranslateService::class => Service\TranslateServiceFactory::class, + Service\ReassessmentTriggerService::class => AutowireFactory::class, /* Validators */ InputValidator\InputValidationTranslator::class => ReflectionBasedAbstractFactory::class, @@ -307,6 +311,14 @@ ReflectionBasedAbstractFactory::class, InputValidator\Referential\PostReferentialDataInputValidator::class => ReflectionBasedAbstractFactory::class, + InputValidator\ReassessmentTrigger\PostReassessmentTriggerDataInputValidator::class => + ReflectionBasedAbstractFactory::class, + InputValidator\ReassessmentTrigger\PatchReassessmentTriggerDataInputValidator::class => + ReflectionBasedAbstractFactory::class, + InputValidator\RiskSource\PostRiskSourceDataInputValidator::class => + ReflectionBasedAbstractFactory::class, + InputValidator\RiskSource\PatchRiskSourceDataInputValidator::class => + ReflectionBasedAbstractFactory::class, InputValidator\RolfTag\PostRolfTagDataInputValidator::class => static function ( Containerinterface $container ) { diff --git a/migrations/db/20210521090000_changeable_operational_impact.php b/migrations/db/20210521090000_changeable_operational_impact.php index 92685720..d7b75c6e 100644 --- a/migrations/db/20210521090000_changeable_operational_impact.php +++ b/migrations/db/20210521090000_changeable_operational_impact.php @@ -266,7 +266,7 @@ public function change() private function createTranslations(array $data, string $type, string $fieldName, string $translationKey): void { $translations = []; - foreach ([1 => 'fr', 2 => 'en', 3 => 'de', 4 => 'nl'] as $langKey => $langLabel) { + foreach ($this->getLanguageIndexToCodeMap() as $langKey => $langLabel) { if (!empty($data[$fieldName . $langKey])) { $translations[] = [ 'anr_id' => $data['anr_id'], @@ -281,6 +281,29 @@ private function createTranslations(array $data, string $type, string $fieldName $this->table('translations')->insert($translations)->save(); } + private function getLanguageIndexToCodeMap(): array + { + $config = []; + $base = getcwd() . '/config/autoload/'; + foreach (['global.php', 'local.php'] as $file) { + $path = $base . $file; + if (file_exists($path)) { + $config = array_replace_recursive($config, require $path); + } + } + + if (!empty($config['languages']) && is_array($config['languages'])) { + $map = []; + foreach ($config['languages'] as $code => $langData) { + $map[(int)$langData['index']] = $code; + } + ksort($map); + return $map; + } + + return [1 => 'fr', 2 => 'en', 3 => 'de', 4 => 'nl']; + } + private function createOperationalInstanceRisksScales(array $currentScaleTypesByAnr): void { $operationalInstanceRisksScalesTable = $this->table('operational_instance_risks_scales'); diff --git a/migrations/db/20211129110500_fix_op_scales_translations.php b/migrations/db/20211129110500_fix_op_scales_translations.php index 7c0e8ae3..863a01f0 100644 --- a/migrations/db/20211129110500_fix_op_scales_translations.php +++ b/migrations/db/20211129110500_fix_op_scales_translations.php @@ -18,9 +18,10 @@ public function change() group by st.label_translation_key, st.anr_id' ); $translationsTable = $this->table('translations'); + $languages = $this->getLanguageCodes(); foreach ($scalesTypesQuery->fetchAll() as $scaleTypeData) { - if ((int)$scaleTypeData['langs_cnt'] < 4) { - foreach (['fr', 'en', 'de', 'nl'] as $lang) { + if ((int)$scaleTypeData['langs_cnt'] < count($languages)) { + foreach ($languages as $lang) { $existsForTheLang = $this->fetchRow( 'select count(*) cnt from `translations` where translation_key = "' . $scaleTypeData['label_translation_key'] . '" @@ -51,8 +52,8 @@ public function change() ); $translationsTable = $this->table('translations'); foreach ($scalesCommentsQuery->fetchAll() as $scaleCommentData) { - if ((int)$scaleCommentData['langs_cnt'] < 4) { - foreach (['fr', 'en', 'de', 'nl'] as $lang) { + if ((int)$scaleCommentData['langs_cnt'] < count($languages)) { + foreach ($languages as $lang) { $existsForTheLang = $this->fetchRow( 'select count(*) cnt from `translations` where translation_key = "' . $scaleCommentData['comment_translation_key'] . '" @@ -73,4 +74,22 @@ public function change() } } } + + private function getLanguageCodes(): array + { + $config = []; + $base = getcwd() . '/config/autoload/'; + foreach (['global.php', 'local.php'] as $file) { + $path = $base . $file; + if (file_exists($path)) { + $config = array_replace_recursive($config, require $path); + } + } + + if (!empty($config['languages']) && is_array($config['languages'])) { + return array_keys($config['languages']); + } + + return ['fr', 'en', 'de', 'nl']; + } } diff --git a/migrations/db/20230901112005_fix_positions_cleanup_db.php b/migrations/db/20230901112005_fix_positions_cleanup_db.php index 5cbeb32e..95e3734f 100644 --- a/migrations/db/20230901112005_fix_positions_cleanup_db.php +++ b/migrations/db/20230901112005_fix_positions_cleanup_db.php @@ -295,18 +295,28 @@ public function change() ->removeIndex(['anr_id', 'code']) ->removeColumn('anr_id') ->save(); - $this->table('rolf_tags') - ->dropForeignKey('anr_id') - ->removeIndex(['anr_id', 'code']) - ->removeColumn('anr_id') - ->save(); - $this->table('rolf_tags')->addIndex(['code'], ['unique' => true])->save(); - $this->table('rolf_risks') - ->dropForeignKey('anr_id') - ->removeIndex(['anr_id', 'code']) - ->removeColumn('anr_id') - ->save(); - $this->table('rolf_risks')->addIndex(['code'], ['unique' => true])->save(); + $rolfTagsTable = $this->table('rolf_tags'); + if ($rolfTagsTable->hasColumn('anr_id')) { + if ($rolfTagsTable->hasForeignKey('anr_id')) { + $rolfTagsTable->dropForeignKey('anr_id')->save(); + } + $this->dropIndexesOnColumn('rolf_tags', 'anr_id'); + $rolfTagsTable->removeColumn('anr_id')->save(); + } + if (!$rolfTagsTable->hasIndex(['code'])) { + $rolfTagsTable->addIndex(['code'], ['unique' => true])->save(); + } + $rolfRisksTable = $this->table('rolf_risks'); + if ($rolfRisksTable->hasColumn('anr_id')) { + if ($rolfRisksTable->hasForeignKey('anr_id')) { + $rolfRisksTable->dropForeignKey('anr_id')->save(); + } + $this->dropIndexesOnColumn('rolf_risks', 'anr_id'); + $rolfRisksTable->removeColumn('anr_id')->save(); + } + if (!$rolfRisksTable->hasIndex(['code'])) { + $rolfRisksTable->addIndex(['code'], ['unique' => true])->save(); + } $this->table('rolf_risks_tags') ->removeColumn('creator') ->removeColumn('created_at') @@ -329,4 +339,22 @@ public function change() ->removeColumn('updated_at') ->save(); } + + /** + * Drops all non-PRIMARY indexes that include the given column. + * Uses SHOW INDEX which only requires SELECT privilege on the table (no information_schema access needed). + */ + private function dropIndexesOnColumn(string $tableName, string $columnName): void + { + $rows = $this->fetchAll("SHOW INDEX FROM `{$tableName}`"); + $namesToDrop = []; + foreach ($rows as $row) { + if ($row['Column_name'] === $columnName && $row['Key_name'] !== 'PRIMARY') { + $namesToDrop[$row['Key_name']] = true; + } + } + foreach (array_keys($namesToDrop) as $indexName) { + $this->execute("ALTER TABLE `{$tableName}` DROP INDEX `{$indexName}`"); + } + } } diff --git a/migrations/db/20260505100000_add_risk_sources.php b/migrations/db/20260505100000_add_risk_sources.php new file mode 100644 index 00000000..5ea54a21 --- /dev/null +++ b/migrations/db/20260505100000_add_risk_sources.php @@ -0,0 +1,119 @@ +execute( + 'CREATE TABLE IF NOT EXISTS `risk_sources` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `label` text NOT NULL, + `is_default` tinyint(1) NOT NULL DEFAULT 0, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `creator` varchar(255) DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updater` varchar(255) DEFAULT NULL, + `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `risk_sources_is_active_indx` (`is_active`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;' + ); + + $defaultRiskSources = [ + [ + 'fr' => 'Attaquant externe', + 'en' => 'External attacker', + 'de' => 'Externer Angreifer', + 'nl' => 'Externe aanvaller', + 'pt' => 'Atacante externo', + ], + [ + 'fr' => 'Utilisateur interne malveillant', + 'en' => 'Internal malicious user', + 'de' => 'Interner böswilliger Benutzer', + 'nl' => 'Interne kwaadwillende gebruiker', + 'pt' => 'Utilizador interno malicioso', + ], + [ + 'fr' => 'Utilisateur interne accidentel', + 'en' => 'Internal accidental user', + 'de' => 'Interner versehentlicher Benutzer', + 'nl' => 'Interne onbedoelde gebruiker', + 'pt' => 'Utilizador interno acidental', + ], + [ + 'fr' => 'Fournisseur / tiers', + 'en' => 'Supplier / third party', + 'de' => 'Lieferant / Drittpartei', + 'nl' => 'Leverancier / derde partij', + 'pt' => 'Fornecedor / terceiro', + ], + [ + 'fr' => 'Défaillance système', + 'en' => 'System failure', + 'de' => 'Systemausfall', + 'nl' => 'Systeemstoring', + 'pt' => 'Falha do sistema', + ], + [ + 'fr' => 'Défaut logiciel', + 'en' => 'Software defect', + 'de' => 'Softwarefehler', + 'nl' => 'Softwaredefect', + 'pt' => 'Defeito de software', + ], + [ + 'fr' => 'Événement naturel', + 'en' => 'Natural event', + 'de' => 'Naturereignis', + 'nl' => 'Natuurgebeurtenis', + 'pt' => 'Evento natural', + ], + [ + 'fr' => 'Faiblesse organisationnelle ou processuelle', + 'en' => 'Organizational or process weakness', + 'de' => 'Organisatorische oder prozessuale Schwäche', + 'nl' => 'Organisatorische of procesmatige zwakte', + 'pt' => 'Fraqueza organizacional ou de processo', + ], + [ + 'fr' => 'Autre', + 'en' => 'Other', + 'de' => 'Sonstige', + 'nl' => 'Overige', + 'pt' => 'Outro', + ], + ]; + foreach ($defaultRiskSources as $labels) { + $escapedLabel = addslashes(json_encode($labels, JSON_THROW_ON_ERROR)); + $this->execute( + "INSERT INTO `risk_sources` (`label`, `is_default`, `is_active`, `creator`, `created_at`) VALUES + ('{$escapedLabel}', 1, 1, 'Migration script', NOW());" + ); + } + + $this->table('instances_risks') + ->addColumn('risk_source_id', 'integer', ['null' => true, 'signed' => false, 'after' => 'asset_id']) + ->addIndex(['risk_source_id'], ['name' => 'risk_source_id']) + ->addForeignKey('risk_source_id', 'risk_sources', 'id', ['delete' => 'SET_NULL', 'update' => 'RESTRICT']) + ->update(); + } + + public function down(): void + { + $this->table('instances_risks') + ->dropForeignKey('risk_source_id') + ->removeIndexByName('risk_source_id') + ->removeColumn('risk_source_id') + ->update(); + + $this->table('risk_sources')->drop()->save(); + } +} diff --git a/migrations/db/20260511100000_add_reassessment_triggers.php b/migrations/db/20260511100000_add_reassessment_triggers.php new file mode 100644 index 00000000..102a0b9c --- /dev/null +++ b/migrations/db/20260511100000_add_reassessment_triggers.php @@ -0,0 +1,221 @@ +execute( + 'CREATE TABLE IF NOT EXISTS `anr_reassessment_triggers` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `trigger_type` text DEFAULT NULL, + `description` text NOT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `position` int(11) NOT NULL DEFAULT 0, + `creator` varchar(255) DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updater` varchar(255) DEFAULT NULL, + `updated_at` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `anr_reassessment_triggers_trigger_type_indx` (`trigger_type`), + KEY `anr_reassessment_triggers_position_indx` (`position`), + KEY `anr_reassessment_triggers_is_active_indx` (`is_active`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;' + ); + + $defaultTriggers = [ + [ + 'trigger_types' => [ + 'fr' => 'Changement système', + 'en' => 'System change', + 'de' => 'Systemänderung', + 'nl' => 'Systeemwijziging', + 'pt' => 'Alteração do sistema', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer l’analyse après des changements importants des systèmes, de l’architecture ou des actifs critiques du périmètre.', + 'en' => 'Reassess the analysis after significant changes to systems, architecture, or critical assets in scope.', + 'de' => 'Die Analyse nach wesentlichen Änderungen an Systemen, Architektur oder kritischen Assets im Geltungsbereich neu bewerten.', + 'nl' => 'Beoordeel de analyse opnieuw na belangrijke wijzigingen aan systemen, architectuur of kritieke activa binnen de scope.', + 'pt' => 'Reavaliar a análise após alterações significativas nos sistemas, na arquitetura ou nos ativos críticos no âmbito.', + ], + 'position' => 1, + ], + [ + 'trigger_types' => [ + 'fr' => 'Nouvelle vulnérabilité', + 'en' => 'New vulnerability', + 'de' => 'Neue Schwachstelle', + 'nl' => 'Nieuwe kwetsbaarheid', + 'pt' => 'Nova vulnerabilidade', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer lorsqu’une nouvelle vulnérabilité affecte des technologies, composants ou fournisseurs utilisés par les actifs du périmètre.', + 'en' => 'Reassess when a new vulnerability affects technologies, components, or suppliers used by scoped assets.', + 'de' => 'Neu bewerten, wenn eine neue Schwachstelle Technologien, Komponenten oder Lieferanten betrifft, die von den Assets im Geltungsbereich genutzt werden.', + 'nl' => 'Beoordeel opnieuw wanneer een nieuwe kwetsbaarheid technologieën, componenten of leveranciers treft die door activa binnen de scope worden gebruikt.', + 'pt' => 'Reavaliar quando uma nova vulnerabilidade afetar tecnologias, componentes ou fornecedores utilizados pelos ativos no âmbito.', + ], + 'position' => 2, + ], + [ + 'trigger_types' => [ + 'fr' => 'Incident de sécurité', + 'en' => 'Security incident', + 'de' => 'Sicherheitsvorfall', + 'nl' => 'Beveiligingsincident', + 'pt' => 'Incidente de segurança', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer après un incident de sécurité significatif, un quasi-incident ou une compromission confirmée affectant les actifs du périmètre.', + 'en' => 'Reassess after a significant security incident, near miss, or confirmed compromise affecting scoped assets.', + 'de' => 'Nach einem wesentlichen Sicherheitsvorfall, einem Beinahe-Vorfall oder einer bestätigten Kompromittierung von Assets im Geltungsbereich neu bewerten.', + 'nl' => 'Beoordeel opnieuw na een belangrijk beveiligingsincident, bijna-incident of bevestigde compromittering die activa binnen de scope raakt.', + 'pt' => 'Reavaliar após um incidente de segurança significativo, um quase incidente ou um comprometimento confirmado que afete os ativos no âmbito.', + ], + 'position' => 3, + ], + [ + 'trigger_types' => [ + 'fr' => 'Changement réglementaire', + 'en' => 'Regulatory change', + 'de' => 'Regulatorische Änderung', + 'nl' => 'Regelgevingswijziging', + 'pt' => 'Alteração regulamentar', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer lorsque des obligations légales, réglementaires, contractuelles ou de conformité pertinentes pour l’analyse évoluent.', + 'en' => 'Reassess when legal, regulatory, contractual, or compliance obligations relevant to the analysis change.', + 'de' => 'Neu bewerten, wenn sich für die Analyse relevante rechtliche, regulatorische, vertragliche oder Compliance-Verpflichtungen ändern.', + 'nl' => 'Beoordeel opnieuw wanneer wettelijke, reglementaire, contractuele of complianceverplichtingen die relevant zijn voor de analyse veranderen.', + 'pt' => 'Reavaliar quando obrigações legais, regulamentares, contratuais ou de conformidade relevantes para a análise mudarem.', + ], + 'position' => 4, + ], + [ + 'trigger_types' => [ + 'fr' => 'Changement fournisseur', + 'en' => 'Supplier change', + 'de' => 'Lieferantenänderung', + 'nl' => 'Leverancierswijziging', + 'pt' => 'Alteração do fornecedor', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer lorsqu’un fournisseur critique, un prestataire de services ou une dépendance externalisée change ou subit une perturbation.', + 'en' => 'Reassess when a critical supplier, service provider, or outsourced dependency changes or experiences disruption.', + 'de' => 'Neu bewerten, wenn sich ein kritischer Lieferant, Dienstleister oder eine ausgelagerte Abhängigkeit ändert oder gestört wird.', + 'nl' => 'Beoordeel opnieuw wanneer een kritieke leverancier, dienstverlener of uitbestede afhankelijkheid verandert of een verstoring ondervindt.', + 'pt' => 'Reavaliar quando um fornecedor crítico, prestador de serviços ou dependência externalizada mudar ou sofrer uma interrupção.', + ], + 'position' => 5, + ], + [ + 'trigger_types' => [ + 'fr' => 'Changement organisationnel', + 'en' => 'Organizational change', + 'de' => 'Organisatorische Änderung', + 'nl' => 'Organisatorische wijziging', + 'pt' => 'Alteração organizacional', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer lorsque des changements majeurs d’organisation, de processus, d’effectifs ou de rôles affectent les responsabilités ou les opérations.', + 'en' => 'Reassess when major organizational, process, staffing, or role changes affect responsibilities or operations.', + 'de' => 'Neu bewerten, wenn wesentliche organisatorische, prozessuale, personelle oder Rollenänderungen Verantwortlichkeiten oder Abläufe beeinflussen.', + 'nl' => 'Beoordeel opnieuw wanneer grote organisatorische wijzigingen, proceswijzigingen, personeelswijzigingen of rolwijzigingen verantwoordelijkheden of activiteiten beïnvloeden.', + 'pt' => 'Reavaliar quando grandes alterações organizacionais, de processos, de pessoal ou de funções afetarem responsabilidades ou operações.', + ], + 'position' => 6, + ], + [ + 'trigger_types' => [ + 'fr' => 'Constat d’audit', + 'en' => 'Audit finding', + 'de' => 'Auditfeststellung', + 'nl' => 'Auditbevinding', + 'pt' => 'Constatação de auditoria', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer après des constats d’audit, des résultats d’évaluation ou des lacunes de contrôle modifiant de manière significative le profil de risque.', + 'en' => 'Reassess after audit findings, assessment results, or control gaps materially change the risk picture.', + 'de' => 'Neu bewerten, wenn Prüfungsfeststellungen, Bewertungsergebnisse oder Kontrolllücken das Risikobild wesentlich verändern.', + 'nl' => 'Beoordeel opnieuw wanneer auditbevindingen, beoordelingsresultaten of controlelacunes het risicobeeld wezenlijk veranderen.', + 'pt' => 'Reavaliar após constatações de auditoria, resultados de avaliação ou falhas de controlo alterarem materialmente o panorama de risco.', + ], + 'position' => 7, + ], + [ + 'trigger_types' => [ + 'fr' => 'Évolution du paysage des menaces', + 'en' => 'Threat landscape change', + 'de' => 'Änderung der Bedrohungslage', + 'nl' => 'Wijziging in dreigingslandschap', + 'pt' => 'Alteração do panorama de ameaças', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer lorsque le renseignement sur les menaces indique une évolution significative du paysage des menaces pour les actifs du périmètre.', + 'en' => 'Reassess when threat intelligence indicates a meaningful change in the threat landscape for scoped assets.', + 'de' => 'Neu bewerten, wenn Bedrohungsinformationen auf eine wesentliche Veränderung der Bedrohungslage für Assets im Geltungsbereich hinweisen.', + 'nl' => 'Beoordeel opnieuw wanneer threat intelligence wijst op een betekenisvolle verandering in het dreigingslandschap voor activa binnen de scope.', + 'pt' => 'Reavaliar quando a inteligência de ameaças indicar uma mudança significativa no panorama de ameaças para os ativos no âmbito.', + ], + 'position' => 8, + ], + [ + 'trigger_types' => [ + 'fr' => 'Nouvelle technologie', + 'en' => 'New technology', + 'de' => 'Neue Technologie', + 'nl' => 'Nieuwe technologie', + 'pt' => 'Nova tecnologia', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer avant d’adopter de nouvelles technologies ou d’introduire des capacités techniques significatives dans le périmètre.', + 'en' => 'Reassess before adopting new technologies or introducing significant technical capabilities into scope.', + 'de' => 'Vor der Einführung neuer Technologien oder wesentlicher technischer Fähigkeiten im Geltungsbereich neu bewerten.', + 'nl' => 'Beoordeel opnieuw voordat nieuwe technologieën worden ingevoerd of belangrijke technische mogelijkheden binnen de scope worden toegevoegd.', + 'pt' => 'Reavaliar antes de adotar novas tecnologias ou introduzir capacidades técnicas significativas no âmbito.', + ], + 'position' => 9, + ], + [ + 'trigger_types' => [ + 'fr' => 'Revue périodique', + 'en' => 'Periodic review', + 'de' => 'Regelmäßige Überprüfung', + 'nl' => 'Periodieke beoordeling', + 'pt' => 'Revisão periódica', + ], + 'descriptions' => [ + 'fr' => 'Réevaluer sur une base périodique planifiée pour confirmer que les hypothèses, contrôles et niveaux de risque restent valides.', + 'en' => 'Reassess on a planned periodic basis to confirm that assumptions, controls, and risk levels remain valid.', + 'de' => 'In geplanten regelmäßigen Abständen neu bewerten, um zu bestätigen, dass Annahmen, Kontrollen und Risikoniveaus weiterhin gültig sind.', + 'nl' => 'Beoordeel opnieuw op geplande periodieke basis om te bevestigen dat aannames, beheersmaatregelen en risiconiveaus geldig blijven.', + 'pt' => 'Reavaliar periodicamente de forma planeada para confirmar que pressupostos, controlos e níveis de risco permanecem válidos.', + ], + 'position' => 10, + ], + ]; + foreach ($defaultTriggers as $trigger) { + $escapedTriggerType = addslashes(json_encode($trigger['trigger_types'], JSON_THROW_ON_ERROR)); + $escapedDescription = addslashes(json_encode($trigger['descriptions'], JSON_THROW_ON_ERROR)); + $position = (int)$trigger['position']; + $this->execute( + "INSERT INTO `anr_reassessment_triggers` + (`trigger_type`, `description`, `is_active`, `position`, `creator`, `created_at`) + VALUES + ('{$escapedTriggerType}', '{$escapedDescription}', 1, {$position}, 'Migration script', NOW());" + ); + } + } + + public function down(): void + { + $this->table('anr_reassessment_triggers')->drop()->save(); + } +} diff --git a/migrations/db/20260515100000_add_risk_review_metadata.php b/migrations/db/20260515100000_add_risk_review_metadata.php new file mode 100644 index 00000000..bef48c41 --- /dev/null +++ b/migrations/db/20260515100000_add_risk_review_metadata.php @@ -0,0 +1,108 @@ +table('anr_reassessment_triggers') + ->addColumn('monitoring_approach', 'text', ['null' => true, 'after' => 'description']) + ->update(); + + $defaultMonitoringApproaches = [ + 1 => [ + 'fr' => 'Surveiller les demandes de changement, les mises en production, les revues d’architecture et l’inventaire des actifs.', + 'en' => 'Monitor change requests, production releases, architecture reviews, and the asset inventory.', + 'de' => 'Änderungsanträge, Produktivsetzungen, Architekturprüfungen und das Asset-Inventar überwachen.', + 'nl' => 'Wijzigingsverzoeken, productiereleases, architectuurbeoordelingen en de inventaris van activa opvolgen.', + 'pt' => 'Monitorizar pedidos de alteração, colocações em produção, revisões de arquitetura e o inventário de ativos.', + ], + 2 => [ + 'fr' => 'Surveiller les scans de vulnérabilités, les bulletins de sécurité, les avis CERT et les alertes des éditeurs/fournisseurs.', + 'en' => 'Monitor vulnerability scans, security bulletins, CERT advisories, and vendor or supplier alerts.', + 'de' => 'Schwachstellenscans, Sicherheitsbulletins, CERT-Hinweise und Hersteller- oder Lieferantenwarnungen überwachen.', + 'nl' => 'Kwetsbaarheidsscans, beveiligingsbulletins, CERT-adviezen en waarschuwingen van leveranciers opvolgen.', + 'pt' => 'Monitorizar análises de vulnerabilidades, boletins de segurança, avisos CERT e alertas de fabricantes ou fornecedores.', + ], + 3 => [ + 'fr' => 'Surveiller les alertes SOC, les tickets d’incident, les journaux de sécurité et les rapports post-incident.', + 'en' => 'Monitor SOC alerts, incident tickets, security logs, and post-incident reports.', + 'de' => 'SOC-Warnungen, Incident-Tickets, Sicherheitsprotokolle und Berichte nach Vorfällen überwachen.', + 'nl' => 'SOC-waarschuwingen, incidenttickets, beveiligingslogboeken en post-incidentrapporten opvolgen.', + 'pt' => 'Monitorizar alertas do SOC, tickets de incidente, registos de segurança e relatórios pós-incidente.', + ], + 4 => [ + 'fr' => 'Surveiller les évolutions réglementaires, les analyses juridiques, les obligations contractuelles et les communications des autorités.', + 'en' => 'Monitor regulatory updates, legal analyses, contractual obligations, and communications from authorities.', + 'de' => 'Regulatorische Aktualisierungen, juristische Analysen, vertragliche Verpflichtungen und Mitteilungen von Behörden überwachen.', + 'nl' => 'Regelgevende updates, juridische analyses, contractuele verplichtingen en communicatie van autoriteiten opvolgen.', + 'pt' => 'Monitorizar atualizações regulamentares, análises jurídicas, obrigações contratuais e comunicações das autoridades.', + ], + 5 => [ + 'fr' => 'Surveiller les évaluations fournisseurs, les SLA, les notifications de service et les incidents affectant les tiers critiques.', + 'en' => 'Monitor supplier assessments, SLAs, service notifications, and incidents affecting critical third parties.', + 'de' => 'Lieferantenbewertungen, SLAs, Servicebenachrichtigungen und Vorfälle bei kritischen Dritten überwachen.', + 'nl' => 'Leveranciersbeoordelingen, SLA’s, serviceberichten en incidenten bij kritieke derden opvolgen.', + 'pt' => 'Monitorizar avaliações de fornecedores, SLA, notificações de serviço e incidentes que afetem terceiros críticos.', + ], + 6 => [ + 'fr' => 'Surveiller les changements d’organisation, les revues de processus, les mouvements d’effectifs et les mises à jour de responsabilités.', + 'en' => 'Monitor organizational changes, process reviews, staffing changes, and responsibility updates.', + 'de' => 'Organisationsänderungen, Prozessprüfungen, Personaländerungen und Aktualisierungen von Verantwortlichkeiten überwachen.', + 'nl' => 'Organisatiewijzigingen, procesbeoordelingen, personeelswijzigingen en updates van verantwoordelijkheden opvolgen.', + 'pt' => 'Monitorizar alterações organizacionais, revisões de processos, alterações de pessoal e atualizações de responsabilidades.', + ], + 7 => [ + 'fr' => 'Surveiller les constats d’audit, les plans d’action, les résultats d’évaluation et les contrôles non conformes.', + 'en' => 'Monitor audit findings, action plans, assessment results, and non-compliant controls.', + 'de' => 'Auditfeststellungen, Maßnahmenpläne, Bewertungsergebnisse und nicht konforme Kontrollen überwachen.', + 'nl' => 'Auditbevindingen, actieplannen, beoordelingsresultaten en niet-conforme controles opvolgen.', + 'pt' => 'Monitorizar constatações de auditoria, planos de ação, resultados de avaliação e controlos não conformes.', + ], + 8 => [ + 'fr' => 'Surveiller le renseignement sur les menaces, les flux externes, les rapports sectoriels et les campagnes actives pertinentes.', + 'en' => 'Monitor threat intelligence, external feeds, sector reports, and relevant active campaigns.', + 'de' => 'Bedrohungsinformationen, externe Feeds, Branchenberichte und relevante aktive Kampagnen überwachen.', + 'nl' => 'Threat intelligence, externe feeds, sectorrapporten en relevante actieve campagnes opvolgen.', + 'pt' => 'Monitorizar inteligência de ameaças, feeds externos, relatórios setoriais e campanhas ativas relevantes.', + ], + 9 => [ + 'fr' => 'Surveiller les projets d’adoption technologique, les dossiers d’architecture, les demandes d’investissement et les essais pilotes.', + 'en' => 'Monitor technology adoption projects, architecture dossiers, investment requests, and pilot initiatives.', + 'de' => 'Technologieeinführungsprojekte, Architekturunterlagen, Investitionsanträge und Pilotinitiativen überwachen.', + 'nl' => 'Projecten voor technologische invoering, architectuurdossiers, investeringsaanvragen en proefinitiatieven opvolgen.', + 'pt' => 'Monitorizar projetos de adoção tecnológica, dossiês de arquitetura, pedidos de investimento e iniciativas-piloto.', + ], + 10 => [ + 'fr' => 'Surveiller le calendrier de revue, les échéances de gouvernance, les rappels de conformité et les plans de contrôle périodiques.', + 'en' => 'Monitor the review schedule, governance deadlines, compliance reminders, and periodic control plans.', + 'de' => 'Den Überprüfungsplan, Governance-Fristen, Compliance-Erinnerungen und regelmäßige Kontrollpläne überwachen.', + 'nl' => 'De beoordelingsplanning, governance-deadlines, complianceherinneringen en periodieke controleplannen opvolgen.', + 'pt' => 'Monitorizar o calendário de revisão, prazos de governação, lembretes de conformidade e planos de controlo periódicos.', + ], + ]; + + foreach ($defaultMonitoringApproaches as $position => $monitoringApproaches) { + $escapedMonitoringApproach = addslashes(json_encode($monitoringApproaches, JSON_THROW_ON_ERROR)); + $this->execute( + "UPDATE `anr_reassessment_triggers` + SET `monitoring_approach` = '{$escapedMonitoringApproach}' + WHERE `position` = {$position} + AND `monitoring_approach` IS NULL" + ); + } + } + + public function down(): void + { + $this->table('anr_reassessment_triggers') + ->removeColumn('monitoring_approach') + ->update(); + } +} diff --git a/src/Entity/ReassessmentTrigger.php b/src/Entity/ReassessmentTrigger.php new file mode 100644 index 00000000..e5c39855 --- /dev/null +++ b/src/Entity/ReassessmentTrigger.php @@ -0,0 +1,26 @@ +id; + } + + public function getTriggerType(): ?string + { + return $this->triggerType; + } + + public function setTriggerType(?string $triggerType): self + { + $this->triggerType = $triggerType; + + return $this; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): self + { + $this->description = $description; + + return $this; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function setIsActive(bool $isActive): self + { + $this->isActive = $isActive; + + return $this; + } + + public function getMonitoringApproach(): ?string + { + return $this->monitoringApproach; + } + + public function setMonitoringApproach(?string $monitoringApproach): self + { + $this->monitoringApproach = $monitoringApproach; + + return $this; + } + + public function getPosition(): int + { + return $this->position; + } + + public function setPosition(int $position): self + { + $this->position = $position; + + return $this; + } + + public function getImplicitPositionRelationsValues(): array + { + return []; + } + + /** + * @return array + */ + public function getTriggerTypeTranslations(): array + { + return $this->decodeTranslations($this->triggerType); + } + + /** + * @param array $triggerTypeTranslations + */ + public function setTriggerTypeTranslations(array $triggerTypeTranslations): self + { + $this->triggerType = $this->encodeTranslations($triggerTypeTranslations); + + return $this; + } + + /** + * @return array + */ + public function getDescriptionTranslations(): array + { + return $this->decodeTranslations($this->description); + } + + /** + * @param array $descriptionTranslations + */ + public function setDescriptionTranslations(array $descriptionTranslations): self + { + $this->description = $this->encodeTranslations($descriptionTranslations); + + return $this; + } + + /** + * @return array + */ + public function getMonitoringApproachTranslations(): array + { + return $this->decodeTranslations($this->monitoringApproach); + } + + /** + * @param array $monitoringApproachTranslations + */ + public function setMonitoringApproachTranslations(array $monitoringApproachTranslations): self + { + $this->monitoringApproach = $monitoringApproachTranslations === [] + ? null + : $this->encodeTranslations($monitoringApproachTranslations); + + return $this; + } + + /** + * @return array + */ + private function decodeTranslations(?string $value): array + { + if ($value === null || $value === '') { + return []; + } + + $decoded = json_decode($value, true); + return $this->normalizeTranslations($decoded); + } + + /** + * @param array $translations + */ + private function encodeTranslations(array $translations): string + { + return json_encode($this->normalizeTranslations($translations), JSON_THROW_ON_ERROR); + } +} diff --git a/src/Entity/RiskSource.php b/src/Entity/RiskSource.php new file mode 100644 index 00000000..2cdbf5e5 --- /dev/null +++ b/src/Entity/RiskSource.php @@ -0,0 +1,24 @@ +id; + } + + public function getLabel(): string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + public function isDefault(): bool + { + return $this->isDefault; + } + + public function setIsDefault(bool $isDefault): self + { + $this->isDefault = $isDefault; + + return $this; + } + + public function isActive(): bool + { + return $this->isActive; + } + + public function setIsActive(bool $isActive): self + { + $this->isActive = $isActive; + + return $this; + } + + /** + * @return array + */ + public function getLabelTranslations(): array + { + return $this->decodeTranslations($this->label); + } + + /** + * @param array $labelTranslations + */ + public function setLabelTranslations(array $labelTranslations): self + { + $this->label = $this->encodeTranslations($labelTranslations); + + return $this; + } + + /** + * @return array + */ + private function decodeTranslations(string $value): array + { + if ($value === '') { + return []; + } + + $decoded = json_decode($value, true); + return $this->normalizeTranslations($decoded); + } + + /** + * @param array $translations + */ + private function encodeTranslations(array $translations): string + { + return json_encode($this->normalizeTranslations($translations), JSON_THROW_ON_ERROR); + } +} diff --git a/src/InputFormatter/ReassessmentTrigger/GetReassessmentTriggersInputFormatter.php b/src/InputFormatter/ReassessmentTrigger/GetReassessmentTriggersInputFormatter.php new file mode 100644 index 00000000..35e0e353 --- /dev/null +++ b/src/InputFormatter/ReassessmentTrigger/GetReassessmentTriggersInputFormatter.php @@ -0,0 +1,41 @@ + [ + 'fieldName' => 'isActive', + 'operator' => Comparison::EQ, + 'type' => 'boolean', + 'convert' => [ + 'value' => 'active', + 'to' => [ + 'value' => true, + ], + ], + ], + ]; + + protected static array $ignoredFilterFieldValues = [ + 'status' => 'all', + ]; + + protected static string $defaultOrderFields = 'position:id'; +} diff --git a/src/InputFormatter/RiskSource/GetRiskSourcesInputFormatter.php b/src/InputFormatter/RiskSource/GetRiskSourcesInputFormatter.php new file mode 100644 index 00000000..d7f1473f --- /dev/null +++ b/src/InputFormatter/RiskSource/GetRiskSourcesInputFormatter.php @@ -0,0 +1,40 @@ + [ + 'fieldName' => 'isActive', + 'default' => true, + 'operator' => Comparison::EQ, + 'type' => 'boolean', + 'convert' => [ + 'value' => 'active', + 'to' => [ + 'value' => true, + ], + ], + ], + ]; + + protected static array $ignoredFilterFieldValues = [ + 'status' => 'all', + ]; + + protected static string $defaultOrderFields = '-isDefault:label'; +} diff --git a/src/Service/InstanceRiskService.php b/src/Service/InstanceRiskService.php index 3224f267..132428fa 100755 --- a/src/Service/InstanceRiskService.php +++ b/src/Service/InstanceRiskService.php @@ -7,7 +7,9 @@ namespace Monarc\Core\Service; +use DateTime; use Monarc\Core\Entity; +use Monarc\Core\Exception\Exception; use Monarc\Core\Service\Traits\ImpactVerificationTrait; use Monarc\Core\Table; use Monarc\Core\Service\Traits\RiskCalculationTrait; diff --git a/src/Service/ReassessmentTriggerService.php b/src/Service/ReassessmentTriggerService.php new file mode 100644 index 00000000..1568cabd --- /dev/null +++ b/src/Service/ReassessmentTriggerService.php @@ -0,0 +1,289 @@ +connectedUser = $connectedUserService->getConnectedUser(); + } + + /** + * @return ReassessmentTrigger[] + */ + public function getList(FormattedInputParams $params): array + { + return $this->reassessmentTriggerTable->findByParams($params); + } + + public function getCount(FormattedInputParams $params): int + { + return $this->reassessmentTriggerTable->countByParams($params, 'id'); + } + + public function get(int $id): ReassessmentTrigger + { + /** @var ReassessmentTrigger $reassessmentTrigger */ + $reassessmentTrigger = $this->reassessmentTriggerTable->findById($id); + + return $reassessmentTrigger; + } + + public function create(array $data): ReassessmentTrigger + { + $reassessmentTrigger = (new ReassessmentTrigger()) + ->setTriggerTypeTranslations($data['triggerTypes']) + ->setDescriptionTranslations($data['descriptions']) + ->setMonitoringApproachTranslations($data['monitoringApproaches']) + ->setIsActive((bool)($data['isActive'] ?? true)) + ->setCreator($this->connectedUser->getEmail()); + + $this->applyCreatePosition($reassessmentTrigger, $data); + $this->reassessmentTriggerTable->save($reassessmentTrigger); + + return $reassessmentTrigger; + } + + public function update(int $id, array $data): ReassessmentTrigger + { + $reassessmentTrigger = $this->get($id); + + if (array_key_exists('triggerTypes', $data)) { + $reassessmentTrigger->setTriggerTypeTranslations($data['triggerTypes']); + } + if (array_key_exists('descriptions', $data)) { + $reassessmentTrigger->setDescriptionTranslations($data['descriptions']); + } + if (array_key_exists('monitoringApproaches', $data)) { + $reassessmentTrigger->setMonitoringApproachTranslations($data['monitoringApproaches']); + } + if (isset($data['isActive'])) { + $reassessmentTrigger->setIsActive((bool)$data['isActive']); + } + + $reassessmentTrigger->setUpdater($this->connectedUser->getEmail()); + + if (isset($data['position']) && (int)$data['position'] !== $reassessmentTrigger->getPosition()) { + $this->applyUpdatedPosition($reassessmentTrigger, (int)$data['position']); + } + + $this->reassessmentTriggerTable->save($reassessmentTrigger); + + return $reassessmentTrigger; + } + + public function delete(int $id): void + { + $reassessmentTrigger = $this->get($id); + $this->reassessmentTriggerTable->incrementPositions( + $reassessmentTrigger->getPosition() + 1, + -1, + -1, + [], + $this->connectedUser->getEmail() + ); + $this->reassessmentTriggerTable->remove($reassessmentTrigger); + } + + /** + * It is called from the ng-client (FO) to fetch the predefined data from the common DB. + * + * @return array> + */ + public function getSelectionData(string $languageCode, bool $includeInactive = false): array + { + $selectionData = []; + foreach ($this->reassessmentTriggerTable->findActiveOrdered($includeInactive) as $reassessmentTrigger) { + $selectionData[] = [ + 'id' => $reassessmentTrigger->getId(), + 'triggerType' => $this->getDisplayTriggerType($reassessmentTrigger, $languageCode), + 'description' => $this->getDisplayDescription($reassessmentTrigger, $languageCode), + 'monitoringApproach' => $this->getDisplayMonitoringApproach($reassessmentTrigger, $languageCode), + 'isActive' => $reassessmentTrigger->isActive(), + 'position' => $reassessmentTrigger->getPosition(), + ]; + } + + return $selectionData; + } + + public function getDisplayTriggerType(ReassessmentTrigger $reassessmentTrigger, ?string $languageCode = null): string + { + return $this->resolveDisplayValue( + $reassessmentTrigger->getTriggerTypeTranslations(), + $reassessmentTrigger->getTriggerType() ?? '', + $languageCode + ); + } + + public function getDisplayDescription(ReassessmentTrigger $reassessmentTrigger, ?string $languageCode = null): string + { + return $this->resolveDisplayValue( + $reassessmentTrigger->getDescriptionTranslations(), + $reassessmentTrigger->getDescription(), + $languageCode + ); + } + + /** + * @return array + */ + public function getTriggerTypes(ReassessmentTrigger $reassessmentTrigger): array + { + return $this->getEditableTranslations( + $reassessmentTrigger->getTriggerTypeTranslations(), + $reassessmentTrigger->getTriggerType() ?? '' + ); + } + + /** + * @return array + */ + public function getDescriptions(ReassessmentTrigger $reassessmentTrigger): array + { + return $this->getEditableTranslations( + $reassessmentTrigger->getDescriptionTranslations(), + $reassessmentTrigger->getDescription() + ); + } + + public function getDisplayMonitoringApproach( + ReassessmentTrigger $reassessmentTrigger, + ?string $languageCode = null + ): string { + return $this->resolveDisplayValue( + $reassessmentTrigger->getMonitoringApproachTranslations(), + $reassessmentTrigger->getMonitoringApproach() ?? '', + $languageCode + ); + } + + /** + * @return array + */ + public function getMonitoringApproaches(ReassessmentTrigger $reassessmentTrigger): array + { + return $this->getEditableTranslations( + $reassessmentTrigger->getMonitoringApproachTranslations(), + $reassessmentTrigger->getMonitoringApproach() ?? '' + ); + } + + /** + * @param array $translations + */ + private function resolveDisplayValue(array $translations, string $fallbackValue, ?string $languageCode = null): string + { + $languageCode ??= $this->getCurrentLanguageCode(); + if (isset($translations[$languageCode]) && $translations[$languageCode] !== '') { + return $translations[$languageCode]; + } + + $defaultLanguageCode = $this->configService->getLanguageCodes()[ + $this->configService->getConfigOption('defaultLanguageIndex', 1) + ] ?? null; + if ($defaultLanguageCode !== null && isset($translations[$defaultLanguageCode])) { + return $translations[$defaultLanguageCode]; + } + + foreach ($translations as $translation) { + if ($translation !== '') { + return $translation; + } + } + + return $fallbackValue; + } + + /** + * @param array $translations + * @return array + */ + private function getEditableTranslations(array $translations, string $fallbackValue): array + { + if ($translations !== []) { + return $translations; + } + + $fallbackValue = trim($fallbackValue); + if ($fallbackValue === '') { + return []; + } + + return [ + $this->getCurrentLanguageCode() => $fallbackValue, + ]; + } + + private function getCurrentLanguageCode(): string + { + return $this->configService->getLanguageCodes()[$this->connectedUser->getLanguage()] + ?? $this->configService->getLanguageCodes()[$this->configService + ->getConfigOption('defaultLanguageIndex', 1)] + ?? 'en'; + } + + private function applyCreatePosition(ReassessmentTrigger $reassessmentTrigger, array $data): void + { + $position = isset($data['position']) ? (int)$data['position'] : 0; + $maxPosition = $this->reassessmentTriggerTable->findMaxPosition([]); + + if ($position <= 0 || $position > $maxPosition + 1) { + $reassessmentTrigger->setPosition($maxPosition + 1); + + return; + } + + $this->reassessmentTriggerTable->incrementPositions( + $position, + -1, + 1, + [], + $this->connectedUser->getEmail() + ); + $reassessmentTrigger->setPosition($position); + } + + private function applyUpdatedPosition(ReassessmentTrigger $reassessmentTrigger, int $newPosition): void + { + $oldPosition = $reassessmentTrigger->getPosition(); + $maxPosition = $this->reassessmentTriggerTable->findMaxPosition([]); + $newPosition = max(1, min($newPosition, $maxPosition)); + + if ($newPosition < $oldPosition) { + $this->reassessmentTriggerTable->incrementPositions( + $newPosition, + $oldPosition - 1, + 1, + [], + $this->connectedUser->getEmail() + ); + } elseif ($newPosition > $oldPosition) { + $this->reassessmentTriggerTable->incrementPositions( + $oldPosition + 1, + $newPosition, + -1, + [], + $this->connectedUser->getEmail() + ); + } + + $reassessmentTrigger->setPosition($newPosition); + } +} diff --git a/src/Service/RiskSourceService.php b/src/Service/RiskSourceService.php new file mode 100644 index 00000000..c88ea5d9 --- /dev/null +++ b/src/Service/RiskSourceService.php @@ -0,0 +1,192 @@ +connectedUser = $connectedUserService->getConnectedUser(); + } + + /** + * @return RiskSource[] + */ + public function getList(FormattedInputParams $params): array + { + return $this->riskSourceTable->findByParams($params); + } + + public function getCount(FormattedInputParams $params): int + { + return $this->riskSourceTable->countByParams($params, 'id'); + } + + public function get(int $id): RiskSource + { + /** @var RiskSource $riskSource */ + $riskSource = $this->riskSourceTable->findById($id); + + return $riskSource; + } + + public function create(array $data): RiskSource + { + $labels = $this->normalizeLabels($data); + + $riskSource = (new RiskSource()) + ->setLabelTranslations($labels) + ->setIsDefault(false) + ->setIsActive((bool)($data['isActive'] ?? true)) + ->setCreator($this->connectedUser->getEmail()); + + $this->riskSourceTable->save($riskSource); + + return $riskSource; + } + + public function update(int $id, array $data): RiskSource + { + $riskSource = $this->get($id); + if (isset($data['isActive'])) { + $riskSource->setIsActive((bool)$data['isActive']); + } + if (!empty($data['labels']) || isset($data['label'])) { + $labels = $this->normalizeLabels($data); + $riskSource->setLabelTranslations($labels); + } + + $riskSource->setUpdater($this->connectedUser->getEmail()); + + $this->riskSourceTable->save($riskSource); + + return $riskSource; + } + + public function delete(int $id): void + { + $riskSource = $this->get($id); + + if ($riskSource->isDefault()) { + throw new Exception('Default risk sources cannot be removed.', 412); + } + + $this->riskSourceTable->remove($riskSource); + } + + /** + * @param RiskSource[] $riskSources + * @return array + */ + public function getDisplayLabelsByRiskSourceId(array $riskSources): array + { + $labelsById = []; + foreach ($riskSources as $riskSource) { + $labelsById[$riskSource->getId()] = $this->resolveDisplayValue( + $riskSource->getLabelTranslations(), + $riskSource->getLabel(), + $this->getCurrentLanguageCode() + ); + } + + return $labelsById; + } + + public function getDisplayLabel(RiskSource $riskSource): string + { + return $this->resolveDisplayValue( + $riskSource->getLabelTranslations(), + $riskSource->getLabel(), + $this->getCurrentLanguageCode() + ); + } + + public function getLabels(RiskSource $riskSource): array + { + return $this->getLabelsWithFallback($riskSource->getLabelTranslations(), $riskSource->getLabel()); + } + + private function normalizeLabels(array $data): array + { + $labels = []; + if (isset($data['labels']) && is_array($data['labels'])) { + foreach ($data['labels'] as $languageCode => $label) { + $trimmedLabel = trim((string)$label); + if ($trimmedLabel !== '') { + $labels[(string)$languageCode] = $trimmedLabel; + } + } + } + + if ($labels === [] && isset($data['label'])) { + $labels[$this->getCurrentLanguageCode()] = trim((string)$data['label']); + } + + return $labels; + } + + private function resolveDisplayValue(array $labels, string $fallbackLabel, ?string $languageCode = null): string + { + $languageCode ??= $this->getCurrentLanguageCode(); + if (isset($labels[$languageCode]) && $labels[$languageCode] !== '') { + return $labels[$languageCode]; + } + + $defaultLanguageCode = $this->configService + ->getLanguageCodes()[$this->configService->getConfigOption('defaultLanguageIndex', 1)] ?? null; + if ($defaultLanguageCode !== null && isset($labels[$defaultLanguageCode])) { + return $labels[$defaultLanguageCode]; + } + + if ($labels !== []) { + return (string)reset($labels); + } + + return $fallbackLabel; + } + + /** + * @param array $labels + * @return array + */ + private function getLabelsWithFallback(array $labels, string $fallbackLabel): array + { + $localizedLabels = []; + foreach ($this->getSupportedLanguageCodes() as $languageCode) { + $localizedLabels[$languageCode] = $this->resolveDisplayValue($labels, $fallbackLabel, $languageCode); + } + + return $localizedLabels; + } + + private function getCurrentLanguageCode(): string + { + return $this->configService->getLanguageCodes()[$this->connectedUser->getLanguage()] + ?? $this->configService->getLanguageCodes()[$this->configService + ->getConfigOption('defaultLanguageIndex', 1)] + ?? 'en'; + } + + private function getSupportedLanguageCodes(): array + { + $languageCodes = $this->configService->getActiveLanguageCodes(); + + return $languageCodes !== [] ? $languageCodes : $this->configService->getLanguageCodes(); + } +} diff --git a/src/Table/InstanceRiskTable.php b/src/Table/InstanceRiskTable.php index e1712a68..119a5d5d 100755 --- a/src/Table/InstanceRiskTable.php +++ b/src/Table/InstanceRiskTable.php @@ -9,6 +9,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\EntityManager; +use Doctrine\ORM\QueryBuilder; use Monarc\Core\Entity\AnrSuperClass; use Monarc\Core\Entity\InstanceRisk; use Monarc\Core\Entity\InstanceRiskSuperClass; @@ -53,6 +54,8 @@ public function findInstancesRisksByParams( ->andWhere('ir.cacheMaxRisk >= -1') ->setParameter('anr', $anr); + $this->applyExtraJoins($queryBuilder); + if (!empty($params['instanceIds'])) { $queryBuilder->andWhere($queryBuilder->expr()->in('i.id', array_map('\intval', $params['instanceIds']))); } @@ -84,6 +87,7 @@ public function findInstancesRisksByParams( 't.label' . $languageIndex . ' LIKE :keywords OR ' . 'v.label' . $languageIndex . ' LIKE :keywords OR ' . 'i.name' . $languageIndex . ' LIKE :keywords OR ' . + $this->getExtraKeywordsCondition() . 'ir.comment LIKE :keywords' )->setParameter('keywords', '%' . $params['keywords'] . '%'); } @@ -128,12 +132,19 @@ public function findInstancesRisksByParams( case 'targetRisk': $queryBuilder->orderBy('ir.cacheTargetedRisk', $orderDirection); break; - default: case 'maxRisk': $queryBuilder->orderBy('ir.cacheMaxRisk', $orderDirection); break; } - if ($params['order'] !== 'instance') { + + $this->applyExtraOrderBy($queryBuilder, $orderField, $orderDirection); + + if (empty($queryBuilder->getDQLPart('orderBy'))) { + // No sorting has been applied, set a default + $queryBuilder->orderBy('ir.cacheMaxRisk', $orderDirection); + } + + if ($orderField !== 'instance') { $queryBuilder->addOrderBy('i.name' . $languageIndex, Criteria::ASC); } $queryBuilder->addOrderBy('t.code', Criteria::ASC) @@ -142,6 +153,25 @@ public function findInstancesRisksByParams( return $queryBuilder->getQuery()->getResult(); } + /** + * Hook for subclasses to add extra joins to the query. + */ + protected function applyExtraJoins(QueryBuilder $queryBuilder): void {} + + /** + * Hook for subclasses to contribute extra conditions to the keywords filter. + * Must return either an empty string or a DQL fragment ending with ' OR '. + */ + protected function getExtraKeywordsCondition(): string + { + return ''; + } + + /** + * Hook for subclasses to apply ordering by fields not known to core. + */ + protected function applyExtraOrderBy(QueryBuilder $queryBuilder, string $orderField, string $direction): void {} + public function findByInstanceAndInstanceRiskRelations( InstanceSuperClass $instance, InstanceRiskSuperClass $instanceRisk diff --git a/src/Table/ReassessmentTriggerTable.php b/src/Table/ReassessmentTriggerTable.php new file mode 100644 index 00000000..5e237c56 --- /dev/null +++ b/src/Table/ReassessmentTriggerTable.php @@ -0,0 +1,41 @@ +getRepository()->createQueryBuilder('t'); + if (!$includeInactive) { + $queryBuilder->where('t.isActive = 1'); + } + + return $queryBuilder + ->orderBy('t.position', Criteria::ASC) + ->addOrderBy('t.id', Criteria::ASC) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Table/RiskSourceTable.php b/src/Table/RiskSourceTable.php new file mode 100644 index 00000000..cb830780 --- /dev/null +++ b/src/Table/RiskSourceTable.php @@ -0,0 +1,56 @@ +getRepository()->createQueryBuilder('rs'); + + if (array_key_exists('isActive', $params) && $params['isActive'] !== null) { + $queryBuilder + ->andWhere('rs.isActive = :isActive') + ->setParameter('isActive', (bool)$params['isActive']); + } + + if (!empty($params['label'])) { + $queryBuilder + ->andWhere('rs.label LIKE :label') + ->setParameter('label', '%' . trim((string)$params['label']) . '%'); + } + + return $queryBuilder + ->orderBy('rs.isDefault', Criteria::DESC) + ->addOrderBy('rs.label', Criteria::ASC) + ->getQuery() + ->getResult(); + } + + public function findOneByLabel(string $label): ?RiskSource + { + return $this->getRepository()->createQueryBuilder('rs') + ->where('LOWER(rs.label) = :label') + ->setParameter('label', mb_strtolower(trim($label))) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/src/Table/TranslationTable.php b/src/Table/TranslationTable.php index 28af3849..e7e7886a 100644 --- a/src/Table/TranslationTable.php +++ b/src/Table/TranslationTable.php @@ -64,6 +64,59 @@ public function findByAnrKeyAndLanguage(AnrSuperClass $anr, string $key, string ->getOneOrNullResult(); } + /** + * @return TranslationSuperClass[] + */ + public function findByTypeAndLanguageIndexedByKeys(string $type, array $keys, string $lang): array + { + if ($keys === []) { + return []; + } + + $queryBuilder = $this->getRepository()->createQueryBuilder('t', 't.key'); + + return $queryBuilder + ->where('t.anr IS NULL') + ->andWhere('t.type = :type') + ->andWhere($queryBuilder->expr()->in('t.key', ':keys')) + ->andWhere('t.lang = :lang') + ->setParameter('type', $type) + ->setParameter('keys', array_values(array_unique($keys))) + ->setParameter('lang', $lang) + ->getQuery() + ->getResult(); + } + + /** + * @return TranslationSuperClass[] + */ + public function findByTypeAndKey(string $type, string $key): array + { + return $this->getRepository()->createQueryBuilder('t') + ->where('t.anr IS NULL') + ->andWhere('t.type = :type') + ->andWhere('t.key = :key') + ->setParameter('type', $type) + ->setParameter('key', $key) + ->getQuery() + ->getResult(); + } + + public function findByTypeKeyAndLanguage(string $type, string $key, string $lang): ?TranslationSuperClass + { + return $this->getRepository()->createQueryBuilder('t') + ->where('t.anr IS NULL') + ->andWhere('t.type = :type') + ->andWhere('t.key = :key') + ->andWhere('t.lang = :lang') + ->setParameter('type', $type) + ->setParameter('key', $key) + ->setParameter('lang', $lang) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + public function deleteListByAnrAndKeys(AnrSuperClass $anr, array $keys): void { $queryBuilder = $this->getRepository()->createQueryBuilder('t'); @@ -75,4 +128,20 @@ public function deleteListByAnrAndKeys(AnrSuperClass $anr, array $keys): void ->getQuery() ->getResult(); } + + public function deleteListByKeys(array $keys): void + { + if ($keys === []) { + return; + } + + $queryBuilder = $this->getRepository()->createQueryBuilder('t'); + $queryBuilder + ->delete() + ->where('t.anr IS NULL') + ->andWhere($queryBuilder->expr()->in('t.key', ':keys')) + ->setParameter('keys', array_values(array_unique($keys))) + ->getQuery() + ->getResult(); + } } diff --git a/src/Traits/TranslationNormalizationTrait.php b/src/Traits/TranslationNormalizationTrait.php new file mode 100644 index 00000000..9e59a3c4 --- /dev/null +++ b/src/Traits/TranslationNormalizationTrait.php @@ -0,0 +1,31 @@ + + */ + public function normalizeTranslations(mixed $translations): array + { + if (!is_array($translations)) { + return []; + } + + $normalizedTranslations = []; + foreach ($translations as $languageCode => $value) { + $trimmedValue = trim((string)$value); + if ($trimmedValue !== '') { + $normalizedTranslations[(string)$languageCode] = $trimmedValue; + } + } + + return $normalizedTranslations; + } +} diff --git a/src/Validator/InputValidator/ReassessmentTrigger/PatchReassessmentTriggerDataInputValidator.php b/src/Validator/InputValidator/ReassessmentTrigger/PatchReassessmentTriggerDataInputValidator.php new file mode 100644 index 00000000..3d0aa0f2 --- /dev/null +++ b/src/Validator/InputValidator/ReassessmentTrigger/PatchReassessmentTriggerDataInputValidator.php @@ -0,0 +1,182 @@ +initialData)) { + unset($validData[$fieldName]); + } + } + + return $validData; + } + + protected function getRules(): array + { + return [ + [ + 'name' => 'triggerType', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): ?string { + $value = trim((string)$value); + + return $value === '' ? null : $value; + }, + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'triggerTypes', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => [$this, 'normalizeTranslations'], + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'description', + 'required' => false, + 'filters' => [ + [ + 'name' => StringTrim::class, + ], + ], + 'validators' => [ + [ + 'name' => StringLength::class, + 'options' => [ + 'min' => 1, + ], + ], + ], + ], + [ + 'name' => 'descriptions', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => [$this, 'normalizeTranslations'], + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'monitoringApproach', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => StringTrim::class, + ], + ], + 'validators' => [ + [ + 'name' => StringLength::class, + 'options' => [ + 'min' => 1, + ], + ], + ], + ], + [ + 'name' => 'monitoringApproaches', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => [$this, 'normalizeTranslations'], + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'isActive', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): ?bool { + if ($value === null || $value === '') { + return null; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }, + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'position', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): ?int { + if ($value === null || $value === '') { + return null; + } + + return (int)$value; + }, + ], + ], + ], + 'validators' => [ + [ + 'name' => NumberComparison::class, + 'options' => [ + 'min' => 1, + 'max' => PHP_INT_MAX, + ], + ], + ], + ], + ]; + } +} diff --git a/src/Validator/InputValidator/ReassessmentTrigger/PostReassessmentTriggerDataInputValidator.php b/src/Validator/InputValidator/ReassessmentTrigger/PostReassessmentTriggerDataInputValidator.php new file mode 100644 index 00000000..7780511f --- /dev/null +++ b/src/Validator/InputValidator/ReassessmentTrigger/PostReassessmentTriggerDataInputValidator.php @@ -0,0 +1,173 @@ + 'triggerType', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): ?string { + $value = trim((string)$value); + + return $value === '' ? null : $value; + }, + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'triggerTypes', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => [$this, 'normalizeTranslations'], + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'description', + 'required' => true, + 'allow_empty' => false, + 'filters' => [ + [ + 'name' => StringTrim::class, + ], + ], + 'validators' => [ + [ + 'name' => StringLength::class, + 'options' => [ + 'min' => 1, + ], + ], + ], + ], + [ + 'name' => 'descriptions', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => [$this, 'normalizeTranslations'], + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'monitoringApproach', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => StringTrim::class, + ], + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): ?string { + $value = trim((string)$value); + + return $value === '' ? null : $value; + }, + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'monitoringApproaches', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => [$this, 'normalizeTranslations'], + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'isActive', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): bool { + if ($value === null || $value === '') { + return true; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }, + ], + ], + ], + 'validators' => [], + ], + [ + 'name' => 'position', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): ?int { + if ($value === null || $value === '') { + return null; + } + + return (int)$value; + }, + ], + ], + ], + 'validators' => [ + [ + 'name' => Between::class, + 'options' => [ + 'min' => 1, + 'max' => PHP_INT_MAX, + ], + ], + ], + ], + ]; + } +} diff --git a/src/Validator/InputValidator/RiskSource/PatchRiskSourceDataInputValidator.php b/src/Validator/InputValidator/RiskSource/PatchRiskSourceDataInputValidator.php new file mode 100644 index 00000000..45c203b3 --- /dev/null +++ b/src/Validator/InputValidator/RiskSource/PatchRiskSourceDataInputValidator.php @@ -0,0 +1,60 @@ + 'label', + 'required' => false, + 'filters' => [ + [ + 'name' => StringTrim::class, + ], + ], + 'validators' => [ + [ + 'name' => StringLength::class, + 'options' => [ + 'min' => 1, + 'max' => 255, + ], + ], + ], + ], + [ + 'name' => 'isActive', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): ?bool { + if ($value === null || $value === '') { + return null; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }, + ], + ], + ], + 'validators' => [], + ], + ]; + } +} diff --git a/src/Validator/InputValidator/RiskSource/PostRiskSourceDataInputValidator.php b/src/Validator/InputValidator/RiskSource/PostRiskSourceDataInputValidator.php new file mode 100644 index 00000000..44f034bf --- /dev/null +++ b/src/Validator/InputValidator/RiskSource/PostRiskSourceDataInputValidator.php @@ -0,0 +1,61 @@ + 'label', + 'required' => true, + 'allow_empty' => false, + 'filters' => [ + [ + 'name' => StringTrim::class, + ], + ], + 'validators' => [ + [ + 'name' => StringLength::class, + 'options' => [ + 'min' => 1, + 'max' => 255, + ], + ], + ], + ], + [ + 'name' => 'isActive', + 'required' => false, + 'allow_empty' => true, + 'filters' => [ + [ + 'name' => Callback::class, + 'options' => [ + 'callback' => static function ($value): bool { + if ($value === null || $value === '') { + return true; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }, + ], + ], + ], + 'validators' => [], + ], + ]; + } +} diff --git a/tests/Unit/InputFormatter/ReassessmentTrigger/GetReassessmentTriggersInputFormatterTest.php b/tests/Unit/InputFormatter/ReassessmentTrigger/GetReassessmentTriggersInputFormatterTest.php new file mode 100644 index 00000000..23f52836 --- /dev/null +++ b/tests/Unit/InputFormatter/ReassessmentTrigger/GetReassessmentTriggersInputFormatterTest.php @@ -0,0 +1,57 @@ +format([]); + + self::assertSame(Criteria::ASC, $formattedParams->getOrder()['position']); + self::assertSame(Criteria::ASC, $formattedParams->getOrder()['id']); + self::assertFalse($formattedParams->hasFilterFor('isActive')); + } + + /** + * @covers \Monarc\Core\InputFormatter\ReassessmentTrigger\GetReassessmentTriggersInputFormatter + */ + public function testFormatConvertsStatusAliasAndPassesAnrFilter(): void + { + $formatter = new GetReassessmentTriggersInputFormatter(); + $anr = new \stdClass(); + + $formattedParams = $formatter->format([ + 'anr' => $anr, + 'status' => 'active', + ]); + + self::assertSame($anr, $formattedParams->getFilterFor('anr')['value']); + self::assertSame(true, $formattedParams->getFilterFor('isActive')['value']); + } + + /** + * @covers \Monarc\Core\InputFormatter\ReassessmentTrigger\GetReassessmentTriggersInputFormatter + */ + public function testFormatIgnoresAllStatusAndFormatsSearch(): void + { + $formatter = new GetReassessmentTriggersInputFormatter(); + + $formattedParams = $formatter->format([ + 'filter' => 'incident', + 'status' => 'all', + ]); + + self::assertSame('incident', $formattedParams->getSearch()['string']); + self::assertFalse($formattedParams->hasFilterFor('isActive')); + } +} diff --git a/tests/Unit/InputFormatter/RiskSource/GetRiskSourcesInputFormatterTest.php b/tests/Unit/InputFormatter/RiskSource/GetRiskSourcesInputFormatterTest.php new file mode 100644 index 00000000..acf3a08e --- /dev/null +++ b/tests/Unit/InputFormatter/RiskSource/GetRiskSourcesInputFormatterTest.php @@ -0,0 +1,57 @@ +format([]); + + self::assertSame(true, $formattedParams->getFilterFor('isActive')['value']); + self::assertSame(Criteria::DESC, $formattedParams->getOrder()['isDefault']); + self::assertSame(Criteria::ASC, $formattedParams->getOrder()['label']); + } + + /** + * @covers \Monarc\Core\InputFormatter\RiskSource\GetRiskSourcesInputFormatter + */ + public function testFormatIgnoresAllStatusAndFormatsSearch(): void + { + $formatter = new GetRiskSourcesInputFormatter(); + + $formattedParams = $formatter->format([ + 'filter' => 'cloud', + 'status' => 'all', + ]); + + self::assertSame('cloud', $formattedParams->getSearch()['string']); + self::assertFalse($formattedParams->hasFilterFor('isActive')); + } + + /** + * @covers \Monarc\Core\InputFormatter\RiskSource\GetRiskSourcesInputFormatter + */ + public function testFormatConvertsActiveStatusAliasAndPassesAnrFilter(): void + { + $formatter = new GetRiskSourcesInputFormatter(); + $anr = new \stdClass(); + + $formattedParams = $formatter->format([ + 'anr' => $anr, + 'status' => 'active', + ]); + + self::assertSame($anr, $formattedParams->getFilterFor('anr')['value']); + self::assertSame(true, $formattedParams->getFilterFor('isActive')['value']); + } +} diff --git a/tests/Unit/Service/ReassessmentTriggerServiceTest.php b/tests/Unit/Service/ReassessmentTriggerServiceTest.php new file mode 100644 index 00000000..d0838869 --- /dev/null +++ b/tests/Unit/Service/ReassessmentTriggerServiceTest.php @@ -0,0 +1,141 @@ +createMock(ReassessmentTriggerTable::class); + $table->expects($this->once()) + ->method('save') + ->with($this->callback(function (ReassessmentTrigger $reassessmentTrigger) { + return $reassessmentTrigger->getMonitoringApproachTranslations() === [ + 'fr' => 'Approche FR', + 'en' => 'Approach EN', + 'de' => 'Ansatz DE', + ]; + })); + + $service = $this->createService($table); + + $reassessmentTrigger = $service->create([ + 'triggerType' => 'Periodic review', + 'description' => 'Review trigger', + 'monitoringApproaches' => [ + 'fr' => 'Approche FR', + 'en' => 'Approach EN', + 'de' => 'Ansatz DE', + ], + ]); + + self::assertSame( + [ + 'fr' => 'Approche FR', + 'en' => 'Approach EN', + 'de' => 'Ansatz DE', + ], + $reassessmentTrigger->getMonitoringApproachTranslations() + ); + } + + /** + * @covers ReassessmentTriggerService::create + */ + public function testCreateDropsUnsupportedAndEmptyMonitoringApproachTranslations(): void + { + $table = $this->createMock(ReassessmentTriggerTable::class); + $table->expects($this->once()) + ->method('save') + ->with($this->callback(function (ReassessmentTrigger $reassessmentTrigger) { + return $reassessmentTrigger->getMonitoringApproachTranslations() === [ + 'fr' => 'Approche FR', + ]; + })); + + $service = $this->createService($table); + + $reassessmentTrigger = $service->create([ + 'triggerType' => 'Periodic review', + 'description' => 'Review trigger', + 'monitoringApproaches' => [ + 'fr' => ' Approche FR ', + 'en' => '', + 'xx' => 'Unsupported language', + ], + ]); + + self::assertSame( + [ + 'fr' => 'Approche FR', + ], + $reassessmentTrigger->getMonitoringApproachTranslations() + ); + } + + /** + * @covers ReassessmentTriggerService::update + */ + public function testUpdateClearsMonitoringApproachWhenAllValuesAreEmpty(): void + { + $reassessmentTrigger = (new ReassessmentTrigger()) + ->setTriggerTypeTranslations(['en' => 'Periodic review']) + ->setDescriptionTranslations(['en' => 'Review trigger']) + ->setMonitoringApproachTranslations(['en' => 'Existing monitoring approach']); + + $table = $this->createMock(ReassessmentTriggerTable::class); + $table->expects($this->once()) + ->method('findById') + ->with(3) + ->willReturn($reassessmentTrigger); + $table->expects($this->once()) + ->method('save') + ->with($this->callback(function (ReassessmentTrigger $updatedTrigger) { + return $updatedTrigger->getMonitoringApproach() === null + && $updatedTrigger->getMonitoringApproachTranslations() === []; + })); + + $service = $this->createService($table); + $updatedTrigger = $service->update(3, [ + 'monitoringApproach' => '', + ]); + + self::assertNull($updatedTrigger->getMonitoringApproach()); + self::assertSame([], $updatedTrigger->getMonitoringApproachTranslations()); + } + + private function createService(ReassessmentTriggerTable $reassessmentTriggerTable): ReassessmentTriggerService + { + $connectedUserService = $this->createMock(ConnectedUserService::class); + $connectedUserService->method('getConnectedUser')->willReturn( + new User([ + 'firstname' => 'Risk', + 'lastname' => 'Owner', + 'email' => 'risk@example.com', + 'language' => 1, + 'creator' => 'Tests', + 'role' => [], + ]) + ); + + $configService = $this->createMock(ConfigService::class); + $configService->method('getLanguageCodes')->willReturn([1 => 'fr', 2 => 'en', 3 => 'de', 4 => 'nl']); + $configService->method('getActiveLanguageCodes')->willReturn(['fr', 'en', 'de', 'nl']); + $configService->method('getConfigOption')->willReturnMap([ + ['defaultLanguageIndex', 1, 1], + ]); + + return new ReassessmentTriggerService($reassessmentTriggerTable, $configService, $connectedUserService); + } +} diff --git a/tests/Unit/Service/RiskSourceServiceTest.php b/tests/Unit/Service/RiskSourceServiceTest.php new file mode 100644 index 00000000..d9bee494 --- /dev/null +++ b/tests/Unit/Service/RiskSourceServiceTest.php @@ -0,0 +1,173 @@ +createMock(RiskSourceTable::class); + $table->expects($this->once()) + ->method('save') + ->with($this->callback(function (RiskSource $riskSource) { + return $riskSource->getLabelTranslations() === ['fr' => 'Cloud provider failure'] + && $riskSource->isActive() === true + && $riskSource->isDefault() === false + && $riskSource->getCreator() === 'risk@example.com'; + }), true); + + $service = $this->createService($table); + + $riskSource = $service->create(['label' => ' Cloud provider failure ']); + + self::assertSame(['fr' => 'Cloud provider failure'], $riskSource->getLabelTranslations()); + self::assertTrue($riskSource->isActive()); + self::assertFalse($riskSource->isDefault()); + } + + /** + * @covers RiskSourceService::update + */ + public function testUpdateChangesLabelsAndStatus(): void + { + $riskSource = (new RiskSource()) + ->setLabelTranslations([ + 'fr' => 'Attaquant externe', + 'en' => 'External attacker', + ]) + ->setIsDefault(true) + ->setIsActive(true); + + $table = $this->createMock(RiskSourceTable::class); + $table->expects($this->once()) + ->method('findById') + ->with(7) + ->willReturn($riskSource); + $table->expects($this->once()) + ->method('save') + ->with($riskSource, true); + + $service = $this->createService($table); + $updatedRiskSource = $service->update(7, [ + 'label' => ' Supplier / third party ', + 'labels' => [ + 'fr' => ' Fournisseur / tiers ', + 'en' => ' Supplier / third party ', + ], + 'isActive' => false, + ]); + + self::assertSame([ + 'fr' => 'Fournisseur / tiers', + 'en' => 'Supplier / third party', + ], $updatedRiskSource->getLabelTranslations()); + self::assertFalse($updatedRiskSource->isActive()); + self::assertSame('risk@example.com', $updatedRiskSource->getUpdater()); + } + + /** + * @covers RiskSourceService::getDisplayLabel + * @covers RiskSourceService::getLabels + */ + public function testDisplayAndLabelsUseConfiguredLanguageFallbacks(): void + { + $riskSource = (new RiskSource())->setLabelTranslations([ + 'en' => 'External attacker', + 'de' => 'Externer Angreifer', + ]); + + $service = $this->createService($this->createMock(RiskSourceTable::class)); + + self::assertSame('External attacker', $service->getDisplayLabel($riskSource)); + self::assertSame([ + 'fr' => 'External attacker', + 'en' => 'External attacker', + 'de' => 'Externer Angreifer', + 'nl' => 'External attacker', + ], $service->getLabels($riskSource)); + } + + /** + * @covers RiskSourceService::delete + */ + public function testDeleteRemovesCustomRiskSourceWhenUnused(): void + { + $riskSource = (new RiskSource()) + ->setLabelTranslations(['en' => 'Temporary source']) + ->setIsDefault(false); + + $table = $this->createMock(RiskSourceTable::class); + $table->expects($this->once()) + ->method('findById') + ->with(9) + ->willReturn($riskSource); + $table->expects($this->once()) + ->method('remove') + ->with($riskSource); + + $service = $this->createService($table); + $service->delete(9); + } + + /** + * @covers RiskSourceService::delete + */ + public function testDeleteRejectsDefaultRiskSource(): void + { + $riskSource = (new RiskSource()) + ->setLabelTranslations(['en' => 'Default source']) + ->setIsDefault(true); + + $table = $this->createMock(RiskSourceTable::class); + $table->expects($this->once()) + ->method('findById') + ->with(4) + ->willReturn($riskSource); + $table->expects($this->never())->method('remove'); + + $service = $this->createService($table); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Default risk sources cannot be removed.'); + $service->delete(4); + } + + /** + * @covers RiskSourceService::delete + */ + private function createService(RiskSourceTable $riskSourceTable): RiskSourceService + { + $connectedUserService = $this->createMock(ConnectedUserService::class); + $connectedUserService->method('getConnectedUser')->willReturn( + new User([ + 'firstname' => 'Risk', + 'lastname' => 'Owner', + 'email' => 'risk@example.com', + 'language' => 1, + 'creator' => 'Tests', + 'role' => [], + ]) + ); + + $configService = $this->createMock(ConfigService::class); + $configService->method('getLanguageCodes')->willReturn([1 => 'fr', 2 => 'en', 3 => 'de', 4 => 'nl']); + $configService->method('getActiveLanguageCodes')->willReturn([1 => 'fr', 2 => 'en', 3 => 'de', 4 => 'nl']); + $configService->method('getConfigOption')->willReturnMap([ + ['defaultLanguageIndex', 1, 1], + ]); + + return new RiskSourceService($riskSourceTable, $configService, $connectedUserService); + } +} diff --git a/tests/Unit/Validator/InputValidator/InstanceRisk/UpdateInstanceRiskDataInputValidatorTest.php b/tests/Unit/Validator/InputValidator/InstanceRisk/UpdateInstanceRiskDataInputValidatorTest.php new file mode 100644 index 00000000..f9c4b7d6 --- /dev/null +++ b/tests/Unit/Validator/InputValidator/InstanceRisk/UpdateInstanceRiskDataInputValidatorTest.php @@ -0,0 +1,57 @@ + 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'riskSourceId' => '5', + 'threatRate' => '3', + 'vulnerabilityRate' => '-', + 'lastReviewDate' => '2026-05-15', + 'reviewFrequency' => ' Quarterly ', + ])); + + $validatedData = $validator->getValidData(); + + self::assertSame(5, $validatedData['riskSourceId']); + self::assertSame(3, $validatedData['threatRate']); + self::assertSame(-1, $validatedData['vulnerabilityRate']); + self::assertSame('2026-05-15', $validatedData['lastReviewDate']); + self::assertSame('Quarterly', $validatedData['reviewFrequency']); + } + + private function createTranslator(): InputValidationTranslator + { + $connectedUserService = $this->createMock(ConnectedUserService::class); + $connectedUserService->method('getConnectedUser')->willReturn(null); + + return new InputValidationTranslator($connectedUserService, [ + 'languages' => ['fr', 'en', 'de', 'nl'], + ]); + } +} diff --git a/tests/Unit/Validator/InputValidator/ReassessmentTrigger/ReassessmentTriggerInputValidatorTest.php b/tests/Unit/Validator/InputValidator/ReassessmentTrigger/ReassessmentTriggerInputValidatorTest.php new file mode 100644 index 00000000..dda83678 --- /dev/null +++ b/tests/Unit/Validator/InputValidator/ReassessmentTrigger/ReassessmentTriggerInputValidatorTest.php @@ -0,0 +1,195 @@ + 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'triggerType' => 'security_incident', + 'description' => ' Reassess after a critical incident. ', + 'position' => '2', + ])); + + $validatedData = $validator->getValidData(); + self::assertSame('security_incident', $validatedData['triggerType']); + self::assertSame('Reassess after a critical incident.', $validatedData['description']); + self::assertTrue($validatedData['isActive']); + self::assertSame(2, $validatedData['position']); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\ReassessmentTrigger\PostReassessmentTriggerDataInputValidator::getRules + */ + public function testPostValidatorAllowsOptionalMonitoringApproach(): void + { + $validator = new PostReassessmentTriggerDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'triggerType' => 'made_up_type', + 'description' => 'Valid trigger type', + 'monitoringApproach' => ' Monitor regulatory updates and supplier notices. ', + ])); + + $validatedData = $validator->getValidData(); + self::assertSame('Monitor regulatory updates and supplier notices.', $validatedData['monitoringApproach']); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\ReassessmentTrigger\PostReassessmentTriggerDataInputValidator::getRules + */ + public function testPostValidatorKeepsPluralTranslationFields(): void + { + $validator = new PostReassessmentTriggerDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'triggerType' => 'System change', + 'triggerTypes' => [ + 'en' => 'System change', + 'fr' => 'Changement du systeme', + ], + 'description' => 'English description', + 'descriptions' => [ + 'en' => 'English description', + 'de' => 'Deutsche Beschreibung', + ], + 'monitoringApproach' => 'SOC alerts', + 'monitoringApproaches' => [ + 'en' => 'SOC alerts', + 'fr' => 'Alertes SOC', + ], + ])); + + $validatedData = $validator->getValidData(); + self::assertSame([ + 'en' => 'System change', + 'fr' => 'Changement du systeme', + ], $validatedData['triggerTypes']); + self::assertSame([ + 'en' => 'English description', + 'de' => 'Deutsche Beschreibung', + ], $validatedData['descriptions']); + self::assertSame([ + 'en' => 'SOC alerts', + 'fr' => 'Alertes SOC', + ], $validatedData['monitoringApproaches']); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\ReassessmentTrigger\PatchReassessmentTriggerDataInputValidator::getRules + */ + public function testPatchValidatorAcceptsPartialPayloadAndAllowsNullType(): void + { + $validator = new PatchReassessmentTriggerDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'triggerType' => ' ', + 'isActive' => '0', + 'position' => '4', + 'monitoringApproach' => ' SOC alerts ', + ])); + + $validatedData = $validator->getValidData(); + self::assertNull($validatedData['triggerType']); + self::assertFalse($validatedData['isActive']); + self::assertSame(4, $validatedData['position']); + self::assertSame('SOC alerts', $validatedData['monitoringApproach']); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\ReassessmentTrigger\PatchReassessmentTriggerDataInputValidator::getValidData + */ + public function testPatchValidatorDoesNotReturnMissingOptionalFields(): void + { + $validator = new PatchReassessmentTriggerDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'position' => '4', + ])); + + $validatedData = $validator->getValidData(); + self::assertSame(['position' => 4], $validatedData); + self::assertArrayNotHasKey('isActive', $validatedData); + self::assertArrayNotHasKey('triggerType', $validatedData); + self::assertArrayNotHasKey('description', $validatedData); + self::assertArrayNotHasKey('monitoringApproach', $validatedData); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\ReassessmentTrigger\PatchReassessmentTriggerDataInputValidator::getRules + * @covers \Monarc\Core\Validator\InputValidator\ReassessmentTrigger\PatchReassessmentTriggerDataInputValidator::getValidData + */ + public function testPatchValidatorKeepsPluralTranslationFields(): void + { + $validator = new PatchReassessmentTriggerDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'triggerTypes' => [ + 'en' => 'System change', + 'fr' => 'Changement du systeme', + ], + 'descriptions' => [ + 'en' => 'English description', + 'de' => 'Deutsche Beschreibung', + ], + 'monitoringApproaches' => [ + 'en' => 'SOC alerts', + 'fr' => 'Alertes SOC', + ], + ])); + + $validatedData = $validator->getValidData(); + self::assertSame([ + 'en' => 'System change', + 'fr' => 'Changement du systeme', + ], $validatedData['triggerTypes']); + self::assertSame([ + 'en' => 'English description', + 'de' => 'Deutsche Beschreibung', + ], $validatedData['descriptions']); + self::assertSame([ + 'en' => 'SOC alerts', + 'fr' => 'Alertes SOC', + ], $validatedData['monitoringApproaches']); + } + + private function createTranslator(): InputValidationTranslator + { + $connectedUserService = $this->createMock(ConnectedUserService::class); + $connectedUserService->method('getConnectedUser')->willReturn(null); + + return new InputValidationTranslator($connectedUserService, [ + 'languages' => ['fr', 'en', 'de', 'nl'], + ]); + } +} diff --git a/tests/Unit/Validator/InputValidator/RiskSource/RiskSourceInputValidatorTest.php b/tests/Unit/Validator/InputValidator/RiskSource/RiskSourceInputValidatorTest.php new file mode 100644 index 00000000..ce25c0d3 --- /dev/null +++ b/tests/Unit/Validator/InputValidator/RiskSource/RiskSourceInputValidatorTest.php @@ -0,0 +1,108 @@ + 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'label' => ' Supplier failure ', + 'isActive' => '0', + ])); + + $validatedData = $validator->getValidData(); + self::assertSame('Supplier failure', $validatedData['label']); + self::assertFalse($validatedData['isActive']); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\RiskSource\PatchRiskSourceDataInputValidator::getRules + */ + public function testPatchValidatorAcceptsPartialPayload(): void + { + $validator = new PatchRiskSourceDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'isActive' => '1', + ])); + + $validatedData = $validator->getValidData(); + self::assertTrue($validatedData['isActive']); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\RiskSource\PatchRiskSourceDataInputValidator::getRules + */ + public function testPatchValidatorDoesNotDefaultIsActiveWhenAbsent(): void + { + $validator = new PatchRiskSourceDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid([ + 'label' => ' Supplier / third party ', + ])); + + $validatedData = $validator->getValidData(); + self::assertSame('Supplier / third party', $validatedData['label']); + self::assertTrue(!array_key_exists('isActive', $validatedData) || $validatedData['isActive'] === null); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\RiskSource\PostRiskSourceDataInputValidator::getRules + */ + public function testPostValidatorDefaultsIsActiveToTrueWhenAbsent(): void + { + $validator = new PostRiskSourceDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertTrue($validator->isValid(['label' => 'ttt'])); + self::assertTrue($validator->getValidData()['isActive']); + } + + /** + * @covers \Monarc\Core\Validator\InputValidator\RiskSource\PostRiskSourceDataInputValidator::getRules + */ + public function testPostValidatorRejectsEmptyLabel(): void + { + $validator = new PostRiskSourceDataInputValidator( + ['defaultLanguageIndex' => 1], + $this->createTranslator() + ); + + self::assertFalse($validator->isValid([ + 'label' => ' ', + ])); + } + + private function createTranslator(): InputValidationTranslator + { + $connectedUserService = $this->createMock(ConnectedUserService::class); + $connectedUserService->method('getConnectedUser')->willReturn(null); + + return new InputValidationTranslator($connectedUserService, [ + 'languages' => ['fr', 'en', 'de', 'nl'], + ]); + } +}