using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Omika.Core.Compatibility; using Omika.Core.Configuration; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Documents; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Domain.Commands; using Omika.Core.Domain.Entities.Organisation; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; using Omika.Core.Runtime; using Omika.Core.Security; using Omika.Core.Services; namespace Omika.Tests.Services; /// /// Tests de : la source autoritaire du /// mot de passe SMTP est SMTP.sMdpStandard, jamais l'ancien /// SMTP.sMdp. Le déchiffrement ne dépend PAS du schéma détecté : la /// migration WinDev alimente les deux schémas, et un état de détection non /// « Omika » ne doit plus rendre le mot de passe indisponible (cause du message /// « Configuration SMTP invalide ou incomplète » à l'envoi). /// [Collection("ApplicationRuntimeContext")] public class SmtpConfigurationServiceTests : IDisposable { private const string Key1 = "K1"; private const string Key2 = "K2"; private const string Key3 = "K3"; private const string Clair = "Omika-Test-SMTP-2026!"; public SmtpConfigurationServiceTests() { ApplicationRuntimeContext.Reset(); } public void Dispose() { ApplicationRuntimeContext.Reset(); } // ===== TEST 16 : sMdp volontairement faux + sMdpStandard valide ===== // Le mot de passe utilisé provient de sMdpStandard (jamais de sMdp). [Fact] public async Task GetAsync_sMdpStandardValide_UtiliseStandardEtIgnoreLegacy() { string sMdpStandard = EncryptStandard(Clair); string json = $$""" { "SMTP": { "sLogin": "compte@test.fr", "sMdp": "LEGACY_SHOULD_NOT_BE_USED", "sMdpStandard": "{{sMdpStandard}}" } } """; SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika) .GetAsync("collab-1", "EMAIL_ENVOI"); Assert.NotNull(config); Assert.Equal(Clair, config!.MotDePasse); } // ===== TEST 17 : sMdpStandard absent → erreur claire, aucune tentative legacy ===== [Fact] public async Task GetAsync_sMdpStandardAbsent_LeveErreurClaire() { string json = """ { "SMTP": { "sLogin": "compte@test.fr", "sMdp": "LEGACY_SHOULD_NOT_BE_USED" } } """; EmailEnvoiException ex = await Assert.ThrowsAsync( () => Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI")); Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type); Assert.StartsWith("Configuration SMTP « compte@test.fr » incomplète", ex.Message); // Le message NOMME la cause (mot de passe) et son origine (préférences // utilisateur) : le message générique ne permettait aucune correction. Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("migré", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("préférences de messagerie", ex.Message, StringComparison.OrdinalIgnoreCase); // Aucune tentative legacy : l'ancien sMdp n'est jamais déchiffré ni exposé. Assert.DoesNotContain("LEGACY_SHOULD_NOT_BE_USED", ex.Message); } // ===== sMdpStandard vide → erreur de configuration contrôlée ===== [Fact] public async Task GetAsync_sMdpStandardVide_LeveErreurConfiguration() { string json = """ { "SMTP": { "sLogin": "compte@test.fr", "sMdpStandard": "" } } """; EmailEnvoiException ex = await Assert.ThrowsAsync( () => Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI")); Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type); Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase); } // ===== sMdpStandard invalide (Base64) → erreur contrôlée, sans secret ===== [Fact] public async Task GetAsync_sMdpStandardInvalide_LeveErreurConfigurationSansSecret() { string json = """ { "SMTP": { "sLogin": "compte@test.fr", "sMdpStandard": "%%%" } } """; EmailEnvoiException ex = await Assert.ThrowsAsync( () => Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI")); Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type); Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase); // Le message utilisateur ne doit jamais contenir de secret ni de détail technique. Assert.DoesNotContain("%%%", ex.Message); Assert.DoesNotContain("sMdpStandard", ex.Message); Assert.DoesNotContain("AES", ex.Message); Assert.DoesNotContain("Base64", ex.Message); } // ===== Schéma Batik : sMdpStandard déchiffré comme pour Omika ===== /// /// Une base Batik (ou une version de base NON détectée) reçoit elle aussi /// SMTP.sMdpStandard. Conditionner le déchiffrement à /// IsOmika laissait le mot de passe vide : l'envoi échouait alors en /// « Configuration SMTP invalide ou incomplète » alors que la valeur était /// parfaitement lisible. /// [Fact] public async Task GetAsync_Batik_sMdpStandardPresent_DechiffreLeMotDePasse() { string json = $$""" { "SMTP": { "sLogin": "compte@test.fr", "sMdp": "LEGACY_SHOULD_NOT_BE_USED", "sMdpStandard": "{{EncryptStandard(Clair)}}", "sAdresseServeur": "smtp.test.fr", "sPortServeur": "465", "nCryptage": 3 } } """; SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Batik) .GetAsync("collab-1", "EMAIL_ENVOI"); Assert.NotNull(config); Assert.Equal(Clair, config!.MotDePasse); } /// /// Configuration de SOCIÉTÉ lue alors que la base n'est pas détectée comme /// Omika : c'est le cas signalé (envoi avec la configuration de la société qui /// répondait « Configuration SMTP invalide ou incomplète »). /// [Fact] public async Task GetAsync_Batik_ConfigurationSociete_EstUtilisable() { SmtpConfigurationService service = Service( json: null, DatabaseSchemaVersion.Batik, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())]); SmtpConfiguration? config = await service.GetAsync("42", "EMAIL_ENVOI_1"); Assert.NotNull(config); Assert.Equal(Clair, config!.MotDePasse); Assert.Equal("smtp.societe.fr", config.Serveur); // La configuration est réellement envoyable : la validation passe. config.Validate(); } /// /// Sans sMdpStandard, aucun mot de passe n'est exploitable : l'ancien /// sMdp (cryptage WinDev Décrypte(sMdp, gsaCleMdp)) n'est JAMAIS /// déchiffré. L'erreur est contrôlée et ne contient aucun secret. /// [Fact] public async Task GetAsync_Batik_sMdpSeul_LeveErreurControlee() { string json = """ { "SMTP": { "sLogin": "compte@test.fr", "sMdp": "LEGACY_SHOULD_NOT_BE_USED" } } """; EmailEnvoiException ex = await Assert.ThrowsAsync( () => Service(json, DatabaseSchemaVersion.Batik).GetAsync("collab-1", "EMAIL_ENVOI")); Assert.Equal(EmailEnvoiErreurType.ConfigurationIncomplete, ex.Type); Assert.Contains("mot de passe", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("LEGACY_SHOULD_NOT_BE_USED", ex.Message); } // ===== Override .NET explicite : reste prioritaire sur sMdpStandard (Omika) ===== [Fact] public async Task GetAsync_Omika_OverridePrioritaireSurStandard() { string sMdpStandard = EncryptStandard(Clair); string json = $$""" { "SMTP": { "sLogin": "compte@test.fr", "sMdpStandard": "{{sMdpStandard}}" } } """; SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika, overridePassword: "OVERRIDE") .GetAsync("collab-1", "EMAIL_ENVOI"); Assert.NotNull(config); Assert.Equal("OVERRIDE", config!.MotDePasse); } // ===== Signature / CC automatiques / auto-CCI (règles §4, §8-§11) ===== [Fact] public async Task GetAsync_Omika_SignatureEtCcParses() { string sigHtml = "

Ma signature

"; string sigB64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(sigHtml)); string json = $$""" { "Compte": { "sCompte": "Compte Principal", "sEmail": "sender@test.fr" }, "SMTP": { "sLogin": "login@test.fr", "sMdpStandard": "{{EncryptStandard(Clair)}}", "sAdresseServeur": "smtp.test.fr", "sPortServeur": "465", "nCryptage": 3, "bufSignature": "{{sigB64}}", "bEnCopieCachee": true, "tabCC_CopieEmail": [ "archive@test.fr", "supervision@test.fr" ] } } """; SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika).GetAsync("collab-1", "EMAIL_ENVOI"); Assert.NotNull(config); Assert.Equal(sigHtml, config!.Signature); Assert.True(config.EnCopieCachee); Assert.Equal(["archive@test.fr", "supervision@test.fr"], config.CcCopieEmail); Assert.Equal("Compte Principal", config.Compte); Assert.Equal("sender@test.fr", config.Email); } // TEST 12 : le DTO de sélection expose sCompte + sEmail, jamais host/port. [Fact] public async Task GetListAsync_Dto_ExposeCompteEtEmail_SansServeur() { string json = """ { "Compte": { "sCompte": "Compte Principal", "sEmail": "sender@test.fr" }, "SMTP": { "sLogin": "login@test.fr", "sAdresseServeur": "smtp.test.fr", "sPortServeur": "465", "nCryptage": 3 } } """; IReadOnlyList list = await Service(json, DatabaseSchemaVersion.Omika).GetListAsync("collab-1"); SmtpConfigurationDto dto = Assert.Single(list); Assert.Equal("Compte Principal", dto.Libelle); Assert.Equal("sender@test.fr", dto.Email); } // ===================================================================== // Configurations de SOCIÉTÉ (Societe.SO_Parametres) // // Défaut corrigé : la liste n'était construite QUE depuis les lignes // ParametreUtilisateur de l'utilisateur. Une configuration de société qui // n'était pas recopiée dans ces lignes (cas courant : le collaborateur est // rattaché à deux sociétés et n'a aucune ligne EMAIL_ENVOI_) // restait invisible pour le mobile — une seule configuration s'affichait. // ===================================================================== /// Configuration d'une société, telle que lue dans SO_Parametres. private static string JsonSociete( string compte = "Compte Société", string email = "societe@test.fr", string login = "societe@test.fr", string? signature = null, bool enCopieCachee = false, string? cc = null, bool inclureMotDePasseStandard = true, string? serveur = "smtp.societe.fr", string? port = "465", int? cryptage = 3) { string sigB64 = signature is null ? string.Empty : Convert.ToBase64String(Encoding.UTF8.GetBytes(signature)); string ccJson = cc is null ? "[]" : $"[\"{cc}\"]"; var champs = new List { $"\"sLogin\": \"{login}\"" }; if (inclureMotDePasseStandard) { champs.Add($"\"sMdpStandard\": \"{EncryptStandard(Clair)}\""); } if (serveur is not null) { champs.Add($"\"sAdresseServeur\": \"{serveur}\""); } if (port is not null) { champs.Add($"\"sPortServeur\": \"{port}\""); } if (cryptage is not null) { champs.Add($"\"nCryptage\": {cryptage}"); } champs.Add($"\"bufSignature\": \"{sigB64}\""); champs.Add($"\"bEnCopieCachee\": {(enCopieCachee ? "true" : "false")}"); champs.Add($"\"tabCC_CopieEmail\": {ccJson}"); return $$""" { "Compte": { "sCompte": "{{compte}}", "sEmail": "{{email}}" }, "SMTP": { {{string.Join(", ", champs)}} } } """; } /// /// Une société du collaborateur apparaît MÊME SANS ligne ParametreUtilisateur /// correspondante, avec l'identifiant conventionnel EMAIL_ENVOI_<SO_PKID>. /// [Fact] public async Task GetListAsync_SocieteSansLigneUtilisateur_ApparaitDansLaListe() { IReadOnlyList list = await Service( json: null, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())]) .GetListAsync("42"); SmtpConfigurationDto dto = Assert.Single(list); Assert.Equal("EMAIL_ENVOI_1", dto.Id); Assert.Equal("Compte Société", dto.Libelle); Assert.Equal("societe@test.fr", dto.Email); } /// /// Société sans compte ni email : elle n'offre aucune configuration d'envoi, /// donc elle n'encombre pas la liste (même règle que l'application de bureau). /// [Fact] public async Task GetListAsync_SocieteSansCompte_EstIgnoree() { const string json = """ { "Compte": { "sCompte": "", "sEmail": "" }, "SMTP": { "sLogin": "", "sAdresseServeur": "", "sPortServeur": "", "nCryptage": 0 } } """; IReadOnlyList list = await Service( json: null, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_3", json)]) .GetListAsync("42"); Assert.Empty(list); } /// /// La ligne ParametreUtilisateur de même code NE CRÉE PAS une seconde entrée : /// elle surcharge la configuration de la société (compte, signature, CC, auto-CCI) /// sans toucher au serveur, au login ni au mot de passe. /// [Fact] public async Task GetListAsync_LigneUtilisateur_SurchargeLaSocieteSansDoublon() { const string surcharge = """ { "Compte": { "sCompte": "Compte Renommé", "sEmail": "renomme@test.fr" }, "SMTP": { "sLogin": "autre-login@test.fr", "sAdresseServeur": "autre.serveur.fr", "sPortServeur": "25", "nCryptage": 1, "bEnCopieCachee": true, "tabCC_CopieEmail": [ "copie@test.fr" ] } } """; IReadOnlyList list = await Service( surcharge, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())], lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", surcharge)]) .GetListAsync("42"); SmtpConfigurationDto dto = Assert.Single(list); Assert.Equal("EMAIL_ENVOI_1", dto.Id); Assert.Equal("Compte Renommé", dto.Libelle); Assert.Equal("renomme@test.fr", dto.Email); // Le serveur et le login restent ceux de la société : la ligne ne peut pas // les changer (règle du bureau), et le mot de passe reste déchiffrable. SmtpConfiguration config = (await Service( surcharge, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())], lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", surcharge)]) .GetAsync("42", "EMAIL_ENVOI_1"))!; Assert.Equal("smtp.societe.fr", config.Serveur); Assert.Equal(465, config.Port); Assert.Equal("societe@test.fr", config.Login); Assert.Equal(Clair, config.MotDePasse); Assert.True(config.EnCopieCachee); Assert.Equal(["copie@test.fr"], config.CcCopieEmail); } // ===================================================================== // RÈGLE MÉTIER : pour une configuration de SOCIÉTÉ, toute l'information de // CONNEXION (serveur, port, cryptage, login, mot de passe) provient de // l'enregistrement de la société. La ligne ParametreUtilisateur de même code // n'apporte que le compte affiché, l'adresse expéditrice et la signature — et // ne comble un champ de connexion QUE si l'enregistrement de la société ne le // contient pas (copie complète écrite par l'application de bureau). // // Symptôme corrigé : une configuration de société refusée à l'envoi avec // « Configuration SMTP invalide ou incomplète » alors que la copie côté // utilisateur contenait la valeur manquante. // ===================================================================== /// /// Mot de passe absent de l'enregistrement de la société mais présent dans la /// copie utilisateur : l'envoi doit aboutir, avec le serveur et le login de la /// société. /// [Fact] public async Task GetAsync_SocieteSansMotDePasseStandard_MotDePasseReprisDesParametresUtilisateur() { string copieUtilisateur = $$""" { "Compte": { "sCompte": "Compte Société", "sEmail": "societe@test.fr" }, "SMTP": { "sLogin": "societe@test.fr", "sMdpStandard": "{{EncryptStandard(Clair)}}", "sAdresseServeur": "smtp.societe.fr", "sPortServeur": "465", "nCryptage": 3 } } """; SmtpConfiguration? config = await Service( json: null, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete(inclureMotDePasseStandard: false))], lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", copieUtilisateur)]) .GetAsync("42", "EMAIL_ENVOI_1"); Assert.NotNull(config); Assert.Equal(Clair, config!.MotDePasse); Assert.Equal("smtp.societe.fr", config.Serveur); config.Validate(); } /// /// Serveur / port / cryptage absents de l'enregistrement de la société mais /// présents dans la copie utilisateur : la configuration reste envoyable. /// [Fact] public async Task GetAsync_SocieteSansServeur_ServeurReprisDesParametresUtilisateur() { const string copieUtilisateur = """ { "Compte": { "sCompte": "Compte Société", "sEmail": "societe@test.fr" }, "SMTP": { "sLogin": "societe@test.fr", "sAdresseServeur": "smtp.copie.fr", "sPortServeur": "587", "nCryptage": 2 } } """; SmtpConfiguration? config = await Service( json: null, DatabaseSchemaVersion.Omika, societes: [ new ParametreUtilisateurSmtp( "EMAIL_ENVOI_1", JsonSociete(serveur: null, port: null, cryptage: null)) ], lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", copieUtilisateur)]) .GetAsync("42", "EMAIL_ENVOI_1"); Assert.NotNull(config); Assert.Equal("smtp.copie.fr", config!.Serveur); Assert.Equal(587, config.Port); Assert.Equal(2, config.Cryptage); Assert.Equal(Clair, config.MotDePasse); config.Validate(); } /// /// Une valeur de connexion FOURNIE par la société n'est jamais remplacée par /// celle des paramètres utilisateur (règle explicitement demandée). /// [Fact] public async Task GetAsync_Societe_ServeurPortEtLoginDeLaSocieteNeSontJamaisRemplaces() { const string copieUtilisateur = """ { "Compte": { "sCompte": "Compte Renommé", "sEmail": "renomme@test.fr" }, "SMTP": { "sLogin": "autre-login@test.fr", "sAdresseServeur": "autre.serveur.fr", "sPortServeur": "25", "nCryptage": 1 } } """; SmtpConfiguration? config = await Service( json: null, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())], lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", copieUtilisateur)]) .GetAsync("42", "EMAIL_ENVOI_1"); Assert.NotNull(config); // Connexion : société. Assert.Equal("smtp.societe.fr", config!.Serveur); Assert.Equal(465, config.Port); Assert.Equal(3, config.Cryptage); Assert.Equal("societe@test.fr", config.Login); // Présentation : préférences utilisateur. Assert.Equal("Compte Renommé", config.Compte); Assert.Equal("renomme@test.fr", config.Email); } /// /// Port écrit en NOMBRE et cryptage en CHAÎNE (variantes de sérialisation /// WindDev) : la configuration ne doit pas disparaître pour autant. /// [Fact] public async Task GetListAsync_PortEnNombreEtCryptageEnChaine_SontAcceptes() { string json = $$""" { "Compte": { "sCompte": "Compte Souple", "sEmail": "souple@test.fr" }, "SMTP": { "sLogin": "souple@test.fr", "sMdpStandard": "{{EncryptStandard(Clair)}}", "sAdresseServeur": "smtp.souple.fr", "sPortServeur": 465, "nCryptage": "3" } } """; SmtpConfigurationDto dto = Assert.Single( await Service(json, DatabaseSchemaVersion.Omika).GetListAsync("42")); // Objet unique sous le code EMAIL_ENVOI : configuration personnelle « code // court » (une entrée de tableau donne EMAIL_ENVOI_Perso_N). Assert.Equal("EMAIL_ENVOI", dto.Id); Assert.Equal("Compte Souple", dto.Libelle); SmtpConfiguration? config = await Service(json, DatabaseSchemaVersion.Omika) .GetAsync("42", "EMAIL_ENVOI"); Assert.NotNull(config); Assert.Equal(465, config!.Port); Assert.Equal(3, config.Cryptage); } /// /// Forme HISTORIQUE (STParamEnvoiEmail) : les champs SMTP sont à la /// RACINE, sans sous-objet « SMTP ». L'application de bureau la convertit à la /// lecture ; le service doit l'accepter au lieu d'ignorer la configuration. /// [Fact] public async Task GetListAsync_FormeHistoriqueSansSousObjetSmtp_EstAcceptee() { const string json = """ { "sLogin": "ancien@test.fr", "sAdresseServeur": "smtp.ancien.fr", "sPortServeur": "587", "nCryptage": 2 } """; SmtpConfigurationDto dto = Assert.Single( await Service(json, DatabaseSchemaVersion.Omika).GetListAsync("42")); Assert.Equal("ancien@test.fr", dto.Libelle); Assert.Equal("ancien@test.fr", dto.Email); } /// /// Sélection mémorisée par une version antérieure de l'application /// (identifiant de configuration devenu inconnu) : avec UNE seule /// configuration disponible, c'est forcément celle-là — l'envoi ne doit pas /// échouer sur un « Sélectionnez une configuration SMTP ». /// [Fact] public async Task GetAsync_IdentifiantInconnu_RepliSurLUniqueConfiguration() { SmtpConfiguration? config = await Service( json: null, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())]) .GetAsync("42", "EMAIL_ENVOI_Perso_7"); Assert.NotNull(config); Assert.Equal("EMAIL_ENVOI_1", config!.Id); Assert.Equal(Clair, config.MotDePasse); } /// /// Avec PLUSIEURS configurations, aucun choix n'est deviné : un identifiant /// inconnu reste introuvable (l'utilisateur doit sélectionner un compte). /// [Fact] public async Task GetAsync_IdentifiantInconnu_PlusieursConfigurations_ResteIntrouvable() { SmtpConfiguration? config = await Service( json: null, DatabaseSchemaVersion.Omika, societes: [ new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete()), new ParametreUtilisateurSmtp( "EMAIL_ENVOI_3", JsonSociete(compte: "Compte 3", email: "s3@test.fr", login: "s3@test.fr")) ]) .GetAsync("42", "EMAIL_ENVOI_Perso_7"); Assert.Null(config); } /// /// Sociétés ET configurations personnelles cohabitent : les sociétés d'abord /// (ordre de l'application de bureau), puis les personnelles. /// [Fact] public async Task GetListAsync_SocietesEtPersonnelles_ToutesExposees() { const string personnelles = """ [ { "Compte": { "sCompte": "Perso 1", "sEmail": "perso1@test.fr" }, "SMTP": { "sLogin": "perso1@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } }, { "Compte": { "sCompte": "Perso 2", "sEmail": "perso2@test.fr" }, "SMTP": { "sLogin": "perso2@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } } ] """; IReadOnlyList list = await Service( personnelles, DatabaseSchemaVersion.Omika, societes: [ new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete()), new ParametreUtilisateurSmtp("EMAIL_ENVOI_3", JsonSociete(compte: "Compte Société 3", email: "s3@test.fr", login: "s3@test.fr")) ]) .GetListAsync("42"); Assert.Equal( ["EMAIL_ENVOI_1", "EMAIL_ENVOI_3", "EMAIL_ENVOI_Perso_0", "EMAIL_ENVOI_Perso_1"], list.Select(d => d.Id).ToArray()); Assert.Equal("Compte Société", list[0].Libelle); Assert.Equal("Compte Société 3", list[1].Libelle); Assert.Equal("Perso 1", list[2].Libelle); Assert.Equal("Perso 2", list[3].Libelle); } /// /// Une ligne EMAIL_ENVOI_<idSoc> dont la société n'existe pas (ou /// n'est pas rattachée au collaborateur) ne crée AUCUNE configuration : c'est la /// règle de l'application de bureau (Lib_Email › DonneListeConfigMail), où /// cette ligne ne fait que compléter une configuration de société déjà présente. /// /// Sans cette règle, la ligne devenait une configuration autonome — libellée /// comme la société mais SANS serveur, sans login et sans mot de passe — que /// l'utilisateur pouvait sélectionner, pour ne récolter que « Configuration SMTP /// invalide ou incomplète » au moment de l'envoi. /// [Fact] public async Task GetListAsync_LigneUtilisateurSansSociete_NEstPasExposee() { IReadOnlyList list = await Service( json: null, DatabaseSchemaVersion.Omika, lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_7", JsonSociete(compte: "Compte Orphelin"))]) .GetListAsync("42"); Assert.Empty(list); } /// /// Configuration personnelle sans login : l'application de bureau exige /// SMTP.sLogin <> "" pour la proposer. Une entrée sans login ne peut /// pas s'authentifier, elle n'est donc pas exposée. /// [Fact] public async Task GetListAsync_ConfigurationPersonnelleSansLogin_EstIgnoree() { const string personnelles = """ [ { "Compte": { "sCompte": "Sans login", "sEmail": "sanslogin@test.fr" }, "SMTP": { "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } }, { "Compte": { "sCompte": "Avec login", "sEmail": "ok@test.fr" }, "SMTP": { "sLogin": "ok@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } } ] """; IReadOnlyList list = await Service(personnelles, DatabaseSchemaVersion.Omika) .GetListAsync("42"); SmtpConfigurationDto dto = Assert.Single(list); Assert.Equal("Avec login", dto.Libelle); } // ===================================================================== // Préférence de messagerie (CHOIX_MESSAGERIE) // // « Externe » = le collaborateur envoie ses courriels depuis SA messagerie, // hors application. Aucune configuration SMTP interne ne doit alors lui être // exposée, même si des lignes EMAIL_ENVOI_* subsistent (elles aussi peuvent // exister sur des sociétés dont le compte est renseigné). // ===================================================================== /// Messagerie externe : aucune configuration, ni société ni personnelle. [Fact] public async Task GetListAsync_MessagerieExterne_AucuneConfiguration() { const string personnelles = """ [ { "Compte": { "sCompte": "Perso", "sEmail": "perso@test.fr" }, "SMTP": { "sLogin": "perso@test.fr", "sAdresseServeur": "smtp.perso.fr", "sPortServeur": "587", "nCryptage": 2 } } ] """; SmtpConfigurationService service = Service( personnelles, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())], preferenceMessagerie: "Externe"); Assert.Empty(await service.GetListAsync("42")); Assert.True(await service.EstMessagerieExterneAsync("42")); // Et l'envoi est refusé : aucune configuration ne peut être résolue. Assert.Null(await service.GetAsync("42", "EMAIL_ENVOI_1")); Assert.Null(await service.GetAsync("42", "EMAIL_ENVOI_Perso_0")); } /// Messagerie « Back-Office » (interne) : les configurations sont exposées. [Fact] public async Task GetListAsync_MessagerieBackOffice_ConfigurationsExposees() { SmtpConfigurationService service = Service( json: null, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())], preferenceMessagerie: "Back-Office"); SmtpConfigurationDto dto = Assert.Single(await service.GetListAsync("42")); Assert.Equal("EMAIL_ENVOI_1", dto.Id); Assert.False(await service.EstMessagerieExterneAsync("42")); } /// /// Préférence absente : comportement interne habituel (aucune régression pour /// les bases où la ligne CHOIX_MESSAGERIE n'existe pas). /// [Fact] public async Task GetListAsync_PreferenceAbsente_ResteInterne() { SmtpConfigurationService service = Service( json: null, DatabaseSchemaVersion.Omika, societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_1", JsonSociete())]); Assert.Single(await service.GetListAsync("42")); Assert.False(await service.EstMessagerieExterneAsync("42")); } // ===== Construction du service ===== private static SmtpConfigurationService Service( string? json, DatabaseSchemaVersion version, string? overridePassword = null, IReadOnlyList? societes = null, IReadOnlyList? lignesUtilisateur = null, string? preferenceMessagerie = null) { ApplicationRuntimeContext.Configure(DatabaseSchemaSelection.Auto); ApplicationRuntimeContext.SetDetectedVersion(version); // La préférence de messagerie arrive dans la MÊME lecture que les // configurations (colonne PU_Code = CHOIX_MESSAGERIE). if (preferenceMessagerie is not null) { lignesUtilisateur = [ .. (lignesUtilisateur ?? [new ParametreUtilisateurSmtp("EMAIL_ENVOI", json)]), new ParametreUtilisateurSmtp("CHOIX_MESSAGERIE", preferenceMessagerie) ]; } var repository = new FakeSmtpConfigurationRepository(json, societes, lignesUtilisateur); var collaborateur = new FakeCollaborateurRepository { Liaison = new CollaborateurUtilisateur(1, 42, null, null) }; var passwords = new SmtpPasswordOptions(); if (overridePassword is not null) { passwords.Passwords["compte@test.fr"] = overridePassword; } return new SmtpConfigurationService( repository, collaborateur, new FakeIdentityResolver(), NullLogger.Instance, Options.Create(passwords), new SmtpPasswordStandardDecryptor(Key1, Key2, Key3)); } // ===== Helper : chiffrement C# équivalent à CrypteMdpSMTPStandard ===== private static string EncryptStandard(string plaintext) { byte[] key = SmtpPasswordStandardDecryptor.DeriveAesKey(Key1, Key2, Key3); using var aes = Aes.Create(); aes.KeySize = 256; aes.BlockSize = 128; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.Key = key; aes.GenerateIV(); using ICryptoTransform encryptor = aes.CreateEncryptor(); byte[] clear = Encoding.UTF8.GetBytes(plaintext); byte[] ciphertext = encryptor.TransformFinalBlock(clear, 0, clear.Length); byte[] payload = new byte[aes.IV.Length + ciphertext.Length]; Buffer.BlockCopy(aes.IV, 0, payload, 0, aes.IV.Length); Buffer.BlockCopy(ciphertext, 0, payload, aes.IV.Length, ciphertext.Length); return Convert.ToBase64String(payload); } // ===== Fakes ===== private sealed class FakeSmtpConfigurationRepository : ISmtpConfigurationRepository { private readonly IReadOnlyList _lignesUtilisateur; private readonly IReadOnlyList _lignesSociete; public FakeSmtpConfigurationRepository( string? json, IReadOnlyList? societes = null, IReadOnlyList? lignesUtilisateur = null) { _lignesUtilisateur = lignesUtilisateur ?? [new ParametreUtilisateurSmtp("EMAIL_ENVOI", json)]; _lignesSociete = societes ?? []; } public Task> GetConfigurationsAsync( long utilisateurId, CancellationToken cancellationToken = default) => Task.FromResult(_lignesUtilisateur); public Task> GetSocieteConfigurationsAsync( long collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(_lignesSociete); } private sealed class FakeCollaborateurRepository : ICollaborateurRepository { public CollaborateurUtilisateur? Liaison { get; set; } public Task> SearchAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task> SearchDetailsAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default) => Task.FromResult(new PagedResult([], 1, 20, 0, 0)); public Task GetDetailsAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetUtilisateurByCollaborateurAsync(string collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult(Liaison); public Task GetParametreSystemeValueAsync(string code, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task CreateAsync(CollaborateurCreateCommand command, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task UpdateAsync(string id, CollaborateurUpdateCommand command, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default) => Task.FromResult>([]); public Task> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default) => Task.FromResult>([]); } private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver { public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical) => physical.Guid ?? Guid.Empty; public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid) => null; public bool IsCanonical(string entityType, Guid publicGuid) => publicGuid != Guid.Empty; } }