Application mono-utilisateur : inscription unique, connexion par JWT (12 h), protection de toutes les routes de données, réinitialisation de mot de passe par email avec repli de journalisation, onboarding réorganisé en deux étapes (inscription → connexion → profil + véhicule). Backend (SchotzIadKmTracker.Api) : - Entités Compte (singleton) et ResetPasswordToken + migration AjoutAuthentification. - Hachage PasswordHasher, JwtService (HS256, durée configurable), IEmailService (MailKit + repli LoggingEmailService selon Smtp:Host). - AuthService (register 409 si compte existant, login 401 générique, forgot-password non-divulgation, reset à usage unique + expiration), AuthController (5 endpoints publics), DTOs + validateurs FluentValidation. - Sécurité pipeline : JwtBearer + filtre d'autorisation global, UseAuthentication avant UseAuthorization ; /health et /api/auth/* publics. Frontend (schotziad-km-tracker, zoneless + signals) : - AuthService (localStorage), authInterceptor (Bearer + purge/redirection 401), authGuard, écrans Login/Register/ForgotPassword/ResetPassword, routes publiques et protection [authGuard, profilConfigureGuard]. Configuration : .env.example et docker-compose.yml (JWT__SecretKey, durées, Smtp__*). Tests : 54 tests backend (propriétés FsCheck P1–P14, exemples, intégration WebApplicationFactory) + tests frontend (AuthService, authGuard, authInterceptor, ResetPasswordComponent). Suite complète au vert. Spec : .kiro/specs/authentification (requirements, design, tasks).
153 lines
5.8 KiB
C#
153 lines
5.8 KiB
C#
using System.Linq;
|
|
using FluentAssertions;
|
|
using FluentValidation.Results;
|
|
using FsCheck;
|
|
using FsCheck.Xunit;
|
|
using SchotzIadKmTracker.Api.Models.DTOs;
|
|
using SchotzIadKmTracker.Api.Validators;
|
|
using Xunit;
|
|
|
|
namespace SchotzIadKmTracker.Tests.Auth;
|
|
|
|
/// <summary>
|
|
/// Tests de la validation FluentValidation des DTOs d'authentification.
|
|
/// La longueur du mot de passe et la validité syntaxique de l'email sont
|
|
/// contrôlées au niveau du validateur (en amont d'AuthService).
|
|
/// </summary>
|
|
public class AuthValidatorTests
|
|
{
|
|
private static readonly RegisterDtoValidator RegisterValidator = new();
|
|
private static readonly LoginDtoValidator LoginValidator = new();
|
|
private static readonly ResetPasswordDtoValidator ResetValidator = new();
|
|
|
|
private const string EmailValide = "utilisateur@example.com";
|
|
|
|
// Feature: authentification, Property 7: Un mot de passe trop court est refusé à l'inscription
|
|
// Toute chaîne de longueur 0..7 => invalide ; >= 8 => valide (avec email valide).
|
|
// Validates: Requirements 1.3
|
|
[Property(MaxTest = 100)]
|
|
public Property P7_RegisterValidator_rejette_mdp_court_accepte_mdp_long()
|
|
{
|
|
// Génère des mots de passe de longueur 0..20 à partir de caractères imprimables.
|
|
var carac = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789ABCDEF!@#".ToCharArray());
|
|
var mdpArb = Arb.From(
|
|
from n in Gen.Choose(0, 20)
|
|
from cs in Gen.ArrayOf(n, carac)
|
|
select new string(cs));
|
|
|
|
return Prop.ForAll(mdpArb, mdp =>
|
|
{
|
|
var resultat = RegisterValidator.Validate(new RegisterDto(EmailValide, mdp));
|
|
|
|
if (mdp.Length < 8)
|
|
{
|
|
resultat.IsValid.Should().BeFalse($"un mot de passe de longueur {mdp.Length} (< 8) doit être rejeté");
|
|
}
|
|
else
|
|
{
|
|
resultat.IsValid.Should().BeTrue($"un mot de passe de longueur {mdp.Length} (>= 8) doit être accepté");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Feature: authentification, Property 14: Un nouveau mot de passe trop court est refusé à la réinitialisation
|
|
// ResetPasswordDtoValidator : NouveauMotDePasse < 8 => invalide ; >= 8 (token non vide) => valide.
|
|
// Validates: Requirements 7.5
|
|
[Property(MaxTest = 100)]
|
|
public Property P14_ResetValidator_rejette_nouveau_mdp_court()
|
|
{
|
|
var carac = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789ABCDEF!@#".ToCharArray());
|
|
var mdpArb = Arb.From(
|
|
from n in Gen.Choose(0, 20)
|
|
from cs in Gen.ArrayOf(n, carac)
|
|
select new string(cs));
|
|
|
|
return Prop.ForAll(mdpArb, mdp =>
|
|
{
|
|
var resultat = ResetValidator.Validate(new ResetPasswordDto("un-jeton-non-vide", mdp));
|
|
|
|
if (mdp.Length < 8)
|
|
{
|
|
resultat.IsValid.Should().BeFalse($"un nouveau mot de passe de longueur {mdp.Length} (< 8) doit être rejeté");
|
|
}
|
|
else
|
|
{
|
|
resultat.IsValid.Should().BeTrue($"un nouveau mot de passe de longueur {mdp.Length} (>= 8) doit être accepté");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Feature: authentification, Property 7 (exemples) : bornes de longueur du mot de passe.
|
|
// Validates: Requirements 1.3
|
|
[Theory]
|
|
[InlineData("", false)]
|
|
[InlineData("1234567", false)] // 7 caractères
|
|
[InlineData("12345678", true)] // 8 caractères
|
|
[InlineData("123456789", true)] // 9 caractères
|
|
public void RegisterValidator_longueur_mdp_bornes(string mdp, bool valideAttendu)
|
|
{
|
|
var resultat = RegisterValidator.Validate(new RegisterDto(EmailValide, mdp));
|
|
resultat.IsValid.Should().Be(valideAttendu);
|
|
}
|
|
|
|
// Feature: authentification, tests d'exemples (6.13) : emails invalides rejetés.
|
|
// Validates: Requirements 1.4
|
|
[Theory]
|
|
[InlineData("a@")]
|
|
[InlineData("abc")]
|
|
[InlineData("@x")]
|
|
[InlineData("")]
|
|
public void RegisterValidator_rejette_les_emails_invalides(string email)
|
|
{
|
|
var resultat = RegisterValidator.Validate(new RegisterDto(email, "MotDePasse123"));
|
|
resultat.IsValid.Should().BeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("a@")]
|
|
[InlineData("abc")]
|
|
[InlineData("@x")]
|
|
[InlineData("")]
|
|
public void LoginValidator_rejette_les_emails_invalides(string email)
|
|
{
|
|
var resultat = LoginValidator.Validate(new LoginDto(email, "MotDePasse123"));
|
|
resultat.IsValid.Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void RegisterValidator_accepte_un_email_valide()
|
|
{
|
|
var resultat = RegisterValidator.Validate(new RegisterDto(EmailValide, "MotDePasse123"));
|
|
resultat.IsValid.Should().BeTrue();
|
|
}
|
|
|
|
// Feature: authentification, tests d'exemples (6.13) : aucun DTO d'auth n'expose de champ de hash.
|
|
// LoginResultDto ne comporte que Token + Expiration (Requirement 8.2).
|
|
// Validates: Requirements 8.2
|
|
[Fact]
|
|
public void LoginResultDto_n_expose_que_token_et_expiration()
|
|
{
|
|
var proprietes = typeof(LoginResultDto).GetProperties().Select(p => p.Name).ToArray();
|
|
|
|
proprietes.Should().BeEquivalentTo(new[] { "Token", "Expiration" });
|
|
proprietes.Should().NotContain(n => n.ToLowerInvariant().Contains("hash"));
|
|
proprietes.Should().NotContain(n => n.ToLowerInvariant().Contains("motdepasse"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Aucun_dto_d_auth_ne_comporte_de_champ_hash_ou_mot_de_passe_hash()
|
|
{
|
|
var types = new[]
|
|
{
|
|
typeof(RegisterDto), typeof(LoginDto), typeof(LoginResultDto),
|
|
typeof(ForgotPasswordDto), typeof(ResetPasswordDto), typeof(CompteExisteDto)
|
|
};
|
|
|
|
foreach (var type in types)
|
|
{
|
|
var noms = type.GetProperties().Select(p => p.Name.ToLowerInvariant());
|
|
noms.Should().NotContain(n => n.Contains("hash"), $"{type.Name} ne doit exposer aucun champ de hash");
|
|
}
|
|
}
|
|
}
|