Files
Omika/tests/Omika.Tests/Services/SmtpConfigurationServiceTests.cs
2026-10-23 18:46:50 +02:00

965 lines
39 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}