Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
@@ -0,0 +1,964 @@
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;
/// <summary>
/// Tests de <see cref="SmtpConfigurationService"/> : la source autoritaire du
/// mot de passe SMTP est <c>SMTP.sMdpStandard</c>, jamais l'ancien
/// <c>SMTP.sMdp</c>. 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).
/// </summary>
[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<EmailEnvoiException>(
() => 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<EmailEnvoiException>(
() => 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<EmailEnvoiException>(
() => 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 =====
/// <summary>
/// Une base Batik (ou une version de base NON détectée) reçoit elle aussi
/// <c>SMTP.sMdpStandard</c>. Conditionner le déchiffrement à
/// <c>IsOmika</c> laissait le mot de passe vide : l'envoi échouait alors en
/// « Configuration SMTP invalide ou incomplète » alors que la valeur était
/// parfaitement lisible.
/// </summary>
[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);
}
/// <summary>
/// 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 »).
/// </summary>
[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();
}
/// <summary>
/// Sans <c>sMdpStandard</c>, aucun mot de passe n'est exploitable : l'ancien
/// <c>sMdp</c> (cryptage WinDev <c>Décrypte(sMdp, gsaCleMdp)</c>) n'est JAMAIS
/// déchiffré. L'erreur est contrôlée et ne contient aucun secret.
/// </summary>
[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<EmailEnvoiException>(
() => 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 = "<p>Ma signature</p>";
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<SmtpConfigurationDto> 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_<idSociété>)
// restait invisible pour le mobile — une seule configuration s'affichait.
// =====================================================================
/// <summary>Configuration d'une société, telle que lue dans SO_Parametres.</summary>
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<string> { $"\"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)}} }
}
""";
}
/// <summary>
/// Une société du collaborateur apparaît MÊME SANS ligne ParametreUtilisateur
/// correspondante, avec l'identifiant conventionnel EMAIL_ENVOI_&lt;SO_PKID&gt;.
/// </summary>
[Fact]
public async Task GetListAsync_SocieteSansLigneUtilisateur_ApparaitDansLaListe()
{
IReadOnlyList<SmtpConfigurationDto> 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);
}
/// <summary>
/// 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).
/// </summary>
[Fact]
public async Task GetListAsync_SocieteSansCompte_EstIgnoree()
{
const string json = """
{
"Compte": { "sCompte": "", "sEmail": "" },
"SMTP": { "sLogin": "", "sAdresseServeur": "", "sPortServeur": "", "nCryptage": 0 }
}
""";
IReadOnlyList<SmtpConfigurationDto> list = await Service(
json: null,
DatabaseSchemaVersion.Omika,
societes: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_3", json)])
.GetListAsync("42");
Assert.Empty(list);
}
/// <summary>
/// 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.
/// </summary>
[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<SmtpConfigurationDto> 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.
// =====================================================================
/// <summary>
/// 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é.
/// </summary>
[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();
}
/// <summary>
/// Serveur / port / cryptage absents de l'enregistrement de la société mais
/// présents dans la copie utilisateur : la configuration reste envoyable.
/// </summary>
[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();
}
/// <summary>
/// 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).
/// </summary>
[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);
}
/// <summary>
/// Port écrit en NOMBRE et cryptage en CHAÎNE (variantes de sérialisation
/// WindDev) : la configuration ne doit pas disparaître pour autant.
/// </summary>
[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);
}
/// <summary>
/// Forme HISTORIQUE (<c>STParamEnvoiEmail</c>) : 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.
/// </summary>
[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);
}
/// <summary>
/// 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 ».
/// </summary>
[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);
}
/// <summary>
/// Avec PLUSIEURS configurations, aucun choix n'est deviné : un identifiant
/// inconnu reste introuvable (l'utilisateur doit sélectionner un compte).
/// </summary>
[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);
}
/// <summary>
/// Sociétés ET configurations personnelles cohabitent : les sociétés d'abord
/// (ordre de l'application de bureau), puis les personnelles.
/// </summary>
[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<SmtpConfigurationDto> 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);
}
/// <summary>
/// Une ligne <c>EMAIL_ENVOI_&lt;idSoc&gt;</c> 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 (<c>Lib_Email › DonneListeConfigMail</c>), 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.
/// </summary>
[Fact]
public async Task GetListAsync_LigneUtilisateurSansSociete_NEstPasExposee()
{
IReadOnlyList<SmtpConfigurationDto> list = await Service(
json: null,
DatabaseSchemaVersion.Omika,
lignesUtilisateur: [new ParametreUtilisateurSmtp("EMAIL_ENVOI_7", JsonSociete(compte: "Compte Orphelin"))])
.GetListAsync("42");
Assert.Empty(list);
}
/// <summary>
/// Configuration personnelle sans login : l'application de bureau exige
/// <c>SMTP.sLogin &lt;&gt; ""</c> pour la proposer. Une entrée sans login ne peut
/// pas s'authentifier, elle n'est donc pas exposée.
/// </summary>
[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<SmtpConfigurationDto> 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é).
// =====================================================================
/// <summary>Messagerie externe : aucune configuration, ni société ni personnelle.</summary>
[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"));
}
/// <summary>Messagerie « Back-Office » (interne) : les configurations sont exposées.</summary>
[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"));
}
/// <summary>
/// Préférence absente : comportement interne habituel (aucune régression pour
/// les bases où la ligne CHOIX_MESSAGERIE n'existe pas).
/// </summary>
[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<ParametreUtilisateurSmtp>? societes = null,
IReadOnlyList<ParametreUtilisateurSmtp>? 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<SmtpConfigurationService>.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<ParametreUtilisateurSmtp> _lignesUtilisateur;
private readonly IReadOnlyList<ParametreUtilisateurSmtp> _lignesSociete;
public FakeSmtpConfigurationRepository(
string? json,
IReadOnlyList<ParametreUtilisateurSmtp>? societes = null,
IReadOnlyList<ParametreUtilisateurSmtp>? lignesUtilisateur = null)
{
_lignesUtilisateur = lignesUtilisateur ?? [new ParametreUtilisateurSmtp("EMAIL_ENVOI", json)];
_lignesSociete = societes ?? [];
}
public Task<IReadOnlyList<ParametreUtilisateurSmtp>> GetConfigurationsAsync(
long utilisateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(_lignesUtilisateur);
public Task<IReadOnlyList<ParametreUtilisateurSmtp>> GetSocieteConfigurationsAsync(
long collaborateurId,
CancellationToken cancellationToken = default)
=> Task.FromResult(_lignesSociete);
}
private sealed class FakeCollaborateurRepository : ICollaborateurRepository
{
public CollaborateurUtilisateur? Liaison { get; set; }
public Task<PagedResult<Collaborateur>> SearchAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Collaborateur>([], 1, 20, 0, 0));
public Task<PagedResult<Collaborateur>> SearchDetailsAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default)
=> Task.FromResult(new PagedResult<Collaborateur>([], 1, 20, 0, 0));
public Task<Collaborateur?> GetDetailsAsync(string id, CancellationToken cancellationToken = default)
=> Task.FromResult<Collaborateur?>(null);
public Task<CollaborateurUtilisateur?> GetUtilisateurByCollaborateurAsync(string collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult(Liaison);
public Task<string?> GetParametreSystemeValueAsync(string code, CancellationToken cancellationToken = default)
=> Task.FromResult<string?>(null);
public Task<Collaborateur> CreateAsync(CollaborateurCreateCommand command, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<Collaborateur?> UpdateAsync(string id, CollaborateurUpdateCommand command, CancellationToken cancellationToken = default)
=> Task.FromResult<Collaborateur?>(null);
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<long>>([]);
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<long>>([]);
}
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;
}
}