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);
});
}
}