using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using FsCheck; using FsCheck.Xunit; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using SchotzIadKmTracker.Api.Models.DTOs; using SchotzIadKmTracker.Api.Models.Entities; using SchotzIadKmTracker.Api.Services.Exceptions; using Xunit; namespace SchotzIadKmTracker.Tests.Auth; /// /// Tests de propriétés (FsCheck.Xunit, >= 100 itérations) du service d'authentification, /// couvrant les Correctness Properties P5–P14 de la conception. /// /// Les tests de flux s'exécutent sur un avec une base SQLite /// en mémoire recréée à chaque cas (isolation), un vrai PasswordHasher / JwtService et un /// IEmailService mocké. /// public class AuthServiceProperties { // --- Générateurs contraints à l'espace d'entrée pertinent ------------------ /// Emails syntaxiquement valides (locale@domaine.tld). private static Arbitrary EmailArb() { var lettre = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789".ToCharArray()); Gen partie(int min, int max) => from n in Gen.Choose(min, max) from cs in Gen.ArrayOf(n, lettre) select new string(cs); var tld = Gen.Elements("com", "fr", "org", "net", "io"); var email = from local in partie(1, 10) from domaine in partie(1, 10) from t in tld select $"{local}@{domaine}.{t}"; return Arb.From(email); } /// Mots de passe d'au moins 8 caractères (imprimables ASCII). private static Arbitrary MotDePasseValideArb() { var carac = Gen.Elements("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%".ToCharArray()); var g = from n in Gen.Choose(8, 24) from cs in Gen.ArrayOf(n, carac) select new string(cs); return Arb.From(g); } private static Property ForEmailEtMdp(Func corps) { return Prop.ForAll(EmailArb(), MotDePasseValideArb(), (email, mdp) => { corps(email, mdp).GetAwaiter().GetResult(); }); } // --------------------------------------------------------------------------- // Feature: authentification, Property 5: L'inscription crée le compte lorsqu'aucun compte n'existe // Pour toute paire (email valide, mdp >= 8), base vide + RegisterAsync => exactement 1 compte avec cet email. // Validates: Requirements 1.1 [Property(MaxTest = 100)] public Property P5_Inscription_cree_le_compte_sur_base_vide() { return ForEmailEtMdp(async (email, mdp) => { using var h = new AuthTestHarness(); await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None); var comptes = await h.Context.Comptes.ToListAsync(); comptes.Should().ContainSingle(); comptes.Single().Email.Should().Be(email.Trim()); }); } // Feature: authentification, Property 6: L'inscription est rejetée lorsqu'un compte existe déjà // Compte déjà présent + 2e RegisterAsync => CompteDejaExistantException, toujours 1 compte. // Validates: Requirements 1.2 [Property(MaxTest = 100)] public Property P6_Deuxieme_inscription_rejetee() { return Prop.ForAll(EmailArb(), EmailArb(), MotDePasseValideArb(), (email1, email2, mdp) => { using var h = new AuthTestHarness(); h.Service.RegisterAsync(new RegisterDto(email1, mdp), CancellationToken.None) .GetAwaiter().GetResult(); Func deuxieme = () => h.Service.RegisterAsync(new RegisterDto(email2, mdp), CancellationToken.None); deuxieme.Should().ThrowAsync().GetAwaiter().GetResult(); h.Context.Comptes.Count().Should().Be(1); }); } // Feature: authentification, Property 8: La connexion émet un jeton sur identifiants corrects et rejette les invalides // Bons identifiants => LoginResultDto avec token non vide validable ; // mauvais mdp ou email inconnu => IdentifiantsInvalidesException. // Validates: Requirements 2.1, 2.2 [Property(MaxTest = 100)] public Property P8_Login_emet_jeton_ou_rejette() { return ForEmailEtMdp(async (email, mdp) => { using var h = new AuthTestHarness(); await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None); // Bons identifiants => token validable. var resultat = await h.Service.LoginAsync(new LoginDto(email, mdp), CancellationToken.None); resultat.Token.Should().NotBeNullOrWhiteSpace(); JwtTestUtils.EstValide(resultat.Token, AuthTestHarness.SecretKey).Should().BeTrue(); // Mauvais mot de passe => rejet. Func mauvaisMdp = () => h.Service.LoginAsync(new LoginDto(email, mdp + "X"), CancellationToken.None); await mauvaisMdp.Should().ThrowAsync(); // Email inconnu => rejet. Func emailInconnu = () => h.Service.LoginAsync(new LoginDto("inconnu-" + email, mdp), CancellationToken.None); await emailInconnu.Should().ThrowAsync(); }); } // Feature: authentification, Property 9: Non-divulgation lors de la demande de réinitialisation // ForgotPasswordAsync ne lève jamais ; email inconnu => aucun token créé et email NON appelé ; // email du compte => exactement un token créé et EnvoyerLienResetAsync appelé une fois. // Validates: Requirements 6.6 [Property(MaxTest = 100)] public Property P9_Non_divulgation_forgot_password() { return ForEmailEtMdp(async (email, mdp) => { using var h = new AuthTestHarness(); await h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None); // Email inconnu : ne lève pas, ne crée aucun token, n'appelle pas l'email. Func inconnu = () => h.Service.ForgotPasswordAsync(new ForgotPasswordDto("autre-" + email), CancellationToken.None); await inconnu.Should().NotThrowAsync(); (await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(0); h.NbAppelsEmail.Should().Be(0); // Email du compte : ne lève pas, crée exactement un token, appelle l'email une fois. Func connu = () => h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None); await connu.Should().NotThrowAsync(); (await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(1); h.NbAppelsEmail.Should().Be(1); }); } // Feature: authentification, Property 10: Expiration du jeton de reset paramétrée // Pour toute ResetTokenLifetimeMinutes > 0, le token créé a Expiration ≈ CreatedAt + minutes et UsedAt == null. // Validates: Requirements 6.1, 6.2, 6.3 [Property(MaxTest = 100)] public Property P10_Expiration_reset_parametree() { var minutesArb = Arb.From(Gen.Choose(1, 100_000)); return Prop.ForAll(EmailArb(), MotDePasseValideArb(), minutesArb, (email, mdp, minutes) => { using var h = new AuthTestHarness(resetTokenLifetimeMinutes: minutes); h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None) .GetAwaiter().GetResult(); h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None) .GetAwaiter().GetResult(); var token = h.Context.ResetPasswordTokens.Single(); token.UsedAt.Should().BeNull(); var attendu = token.CreatedAt.AddMinutes(minutes); token.Expiration.Should().BeCloseTo(attendu, TimeSpan.FromSeconds(5)); }); } // Feature: authentification, Property 11: Une réinitialisation valide remplace le mot de passe // Après ForgotPassword (token capturé via le lien mocké), ResetPasswordAsync(token, nouveauMdp>=8) // => LoginAsync réussit avec le nouveau mdp et échoue avec l'ancien. // Validates: Requirements 7.1 [Property(MaxTest = 100)] public Property P11_Reset_valide_remplace_mdp() { return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(), (email, ancienMdp, nouveauMdp) => { // On veut deux mots de passe distincts pour tester l'ancien vs le nouveau. if (ancienMdp == nouveauMdp) { nouveauMdp += "Z9"; } using var h = new AuthTestHarness(); h.Service.RegisterAsync(new RegisterDto(email, ancienMdp), CancellationToken.None) .GetAwaiter().GetResult(); h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None) .GetAwaiter().GetResult(); var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!); h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None) .GetAwaiter().GetResult(); // Login OK avec le nouveau mot de passe. Func nouveau = () => h.Service.LoginAsync(new LoginDto(email, nouveauMdp), CancellationToken.None); nouveau.Should().NotThrowAsync().GetAwaiter().GetResult(); // Login KO avec l'ancien mot de passe. Func ancien = () => h.Service.LoginAsync(new LoginDto(email, ancienMdp), CancellationToken.None); ancien.Should().ThrowAsync().GetAwaiter().GetResult(); }); } // Feature: authentification, Property 12: Un jeton de reset est à usage unique // Un token utilisé une fois => 2e ResetPasswordAsync avec le même token lève LienResetInvalideException ; UsedAt renseigné. // Validates: Requirements 7.2, 7.4 [Property(MaxTest = 100)] public Property P12_Reset_usage_unique() { return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(), (email, mdp, nouveau) => { using var h = new AuthTestHarness(); h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None) .GetAwaiter().GetResult(); h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None) .GetAwaiter().GetResult(); var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!); // Premier usage : succès. h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveau), CancellationToken.None) .GetAwaiter().GetResult(); // UsedAt renseigné. h.Context.ResetPasswordTokens.Single().UsedAt.Should().NotBeNull(); // Deuxième usage du même token : rejeté (avant toute prise en compte du mot de passe). Func deuxieme = () => h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveau), CancellationToken.None); deuxieme.Should().ThrowAsync().GetAwaiter().GetResult(); }); } // Feature: authentification, Property 13: Un jeton de reset expiré ou inconnu est rejeté // Token expiré (Expiration forcée dans le passé) => LienResetExpireException ; // token inconnu => LienResetInvalideException ; le mdp du compte reste inchangé. // Validates: Requirements 7.3, 7.4 [Property(MaxTest = 100)] public Property P13_Reset_expire_ou_inconnu_rejete() { return Prop.ForAll(EmailArb(), MotDePasseValideArb(), MotDePasseValideArb(), (email, mdp, nouveauMdp) => { using var h = new AuthTestHarness(); h.Service.RegisterAsync(new RegisterDto(email, mdp), CancellationToken.None) .GetAwaiter().GetResult(); var hashInitial = h.Context.Comptes.Single().MotDePasseHash; // --- Token inconnu de la base --- Func inconnu = () => h.Service.ResetPasswordAsync(new ResetPasswordDto("token-inexistant", nouveauMdp), CancellationToken.None); inconnu.Should().ThrowAsync().GetAwaiter().GetResult(); // --- Token expiré : on génère un token via forgot puis on force Expiration dans le passé --- h.Service.ForgotPasswordAsync(new ForgotPasswordDto(email), CancellationToken.None) .GetAwaiter().GetResult(); var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!); var token = h.Context.ResetPasswordTokens.Single(); token.Expiration = DateTime.UtcNow.AddMinutes(-1); h.Context.SaveChanges(); Func expire = () => h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None); expire.Should().ThrowAsync().GetAwaiter().GetResult(); // Le mot de passe du compte reste inchangé dans les deux cas. h.Context.Entry(h.Context.Comptes.Single()).Reload(); h.Context.Comptes.Single().MotDePasseHash.Should().Be(hashInitial); }); } }