965 lines
39 KiB
C#
965 lines
39 KiB
C#
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_<SO_PKID>.
|
||
/// </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_<idSoc></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 <> ""</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;
|
||
}
|
||
}
|