Ajout de l'authentification (compte unique, JWT, reset par email)
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).
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests d'intégration de l'authentification (feature authentification), exécutés sur
|
||||
/// l'hôte réel via <see cref="WebApplicationFactory{TEntryPoint}"/> (Program expose
|
||||
/// <c>public partial class Program {}</c>).
|
||||
///
|
||||
/// Contexte de conception (design.md, section « Testing Strategy » — Tests d'intégration) :
|
||||
/// - Toutes les routes MVC de données sont protégées par un filtre d'autorisation global
|
||||
/// (RequireAuthenticatedUser) ; AuthController est [AllowAnonymous] et /health est public.
|
||||
/// - La configuration exige JWT__SecretKey (>= 32 octets) : fournie ici via une config de test.
|
||||
/// - La base est ISOLÉE par test : chaque test crée sa propre factory pointant vers un fichier
|
||||
/// SQLite temporaire unique (Guid). Cela évite le conflit « compte unique » entre tests
|
||||
/// (un seul register possible par base). Le fichier est supprimé en fin de test (IDisposable).
|
||||
/// - Smtp__Host est laissé VIDE pour forcer le repli journalisation (LoggingEmailService),
|
||||
/// afin de ne jamais tenter d'envoi SMTP réel pendant les tests.
|
||||
///
|
||||
/// Chaque test instancie <see cref="IsolatedAuthFactory"/> dans un bloc <c>using</c> :
|
||||
/// base neuve garantie et nettoyage du fichier temporaire.
|
||||
/// </summary>
|
||||
public class AuthIntegrationTests
|
||||
{
|
||||
// Clé JWT de test : >= 32 octets (exigence de configuration). Valeur factice, jamais utilisée en production.
|
||||
private const string JwtTestSecretKey = "test-secret-key-integration-authentification-0123456789";
|
||||
|
||||
/// <summary>
|
||||
/// Factory de test isolée : chaque instance possède son propre fichier SQLite temporaire
|
||||
/// (nom unique via Guid) et surcharge la configuration (ConnectionStrings__Default,
|
||||
/// JWT__SecretKey, Smtp__Host vide) via AddInMemoryCollection. Le fichier est supprimé
|
||||
/// à la libération de la factory.
|
||||
/// </summary>
|
||||
private sealed class IsolatedAuthFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _dbPath =
|
||||
Path.Combine(Path.GetTempPath(), $"schotz-auth-it-{Guid.NewGuid():N}.db");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
// Program.cs lit la configuration (Jwt, ConnectionStrings, Smtp) DÈS la construction
|
||||
// du builder (via builder.Configuration.GetSection(...).Get<...>() pour enregistrer
|
||||
// AddJwtBearer et le DbContext). Ces valeurs doivent donc être présentes très tôt.
|
||||
// UseSetting alimente la configuration de l'hôte web, disponible au moment où Program
|
||||
// consomme ces sections (contrairement à ConfigureAppConfiguration, appliquée trop tard
|
||||
// pour ces lectures « eager »). On isole ainsi la base par factory sans variable d'env
|
||||
// globale au process (qui casserait l'isolation entre factories parallèles).
|
||||
builder.UseSetting("ConnectionStrings:Default", $"Data Source={_dbPath}");
|
||||
builder.UseSetting("Jwt:SecretKey", JwtTestSecretKey);
|
||||
builder.UseSetting("Smtp:Host", string.Empty);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
TryDeleteDbFiles();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteDbFiles()
|
||||
{
|
||||
// SQLite peut créer des fichiers annexes (-wal, -shm) : on les nettoie aussi.
|
||||
foreach (var path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" })
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nettoyage best-effort : un fichier temporaire résiduel ne doit pas faire échouer le test.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crée un client HTTP sans redirection auto ni cookies : on teste les codes de statut bruts.
|
||||
/// </summary>
|
||||
private static HttpClient CreerClient(IsolatedAuthFactory factory) =>
|
||||
factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
});
|
||||
|
||||
private static RegisterDto NouveauCompte() =>
|
||||
new($"user-{Guid.NewGuid():N}@example.com", "MotDePasse123!");
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tâche 8.2 — Protection des routes (Requirements 4.1, 4.2, 4.4, 4.5)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/vehicules")]
|
||||
[InlineData("/api/profil")]
|
||||
[InlineData("/api/bareme/2025")]
|
||||
public async Task Route_de_donnees_sans_jeton_renvoie_401(string route)
|
||||
{
|
||||
// Requirement 4.2 / 4.5 : sans jeton, une route de données est rejetée (401).
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/auth/compte-existe")]
|
||||
[InlineData("/health")]
|
||||
public async Task Route_publique_accessible_sans_jeton_renvoie_200(string route)
|
||||
{
|
||||
// Requirement 4.4 : les endpoints publics (auth + /health) restent accessibles sans jeton.
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_de_donnees_avec_jeton_valide_renvoie_2xx()
|
||||
{
|
||||
// Requirement 4.1 : avec un jeton valide, la route de données est autorisée (2xx).
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var token = await RegisterEtLoginAsync(client);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
var response = await client.GetAsync("/api/vehicules");
|
||||
|
||||
response.IsSuccessStatusCode.Should().BeTrue(
|
||||
"un jeton valide doit autoriser l'accès à une route protégée (statut {0})", response.StatusCode);
|
||||
((int)response.StatusCode).Should().BeInRange(200, 299);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tâche 8.3 — Parcours bout en bout (Requirements 1.1, 2.1, 7.1)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Bout_en_bout_register_puis_login_puis_route_protegee()
|
||||
{
|
||||
// Requirement 1.1 (register) -> 2.1 (login + token) -> accès protégé avec le token.
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var compte = NouveauCompte();
|
||||
|
||||
// Register (200) — Requirement 1.1.
|
||||
var registerResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/register", new RegisterDto(compte.Email, compte.MotDePasse));
|
||||
registerResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// Login (200 + token) — Requirement 2.1.
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login", new LoginDto(compte.Email, compte.MotDePasse));
|
||||
loginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var loginResult = await loginResponse.Content.ReadFromJsonAsync<LoginResultDto>();
|
||||
loginResult.Should().NotBeNull();
|
||||
loginResult!.Token.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
// Accès à une route protégée avec le token obtenu (2xx).
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginResult.Token);
|
||||
var protectedResponse = await client.GetAsync("/api/profil");
|
||||
|
||||
protectedResponse.IsSuccessStatusCode.Should().BeTrue(
|
||||
"le token émis par login doit ouvrir l'accès protégé (statut {0})", protectedResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bout_en_bout_forgot_password_puis_reset_password_avec_token_invalide()
|
||||
{
|
||||
// Requirement 7.1 (cycle de réinitialisation).
|
||||
//
|
||||
// Choix d'implémentation pour l'e2e du reset (option b, documentée) :
|
||||
// le jeton de reset n'est jamais persisté en clair (seul son hachage SHA-256 est stocké
|
||||
// dans ResetPasswordTokens.TokenHash) et le jeton en clair n'est disponible que dans le
|
||||
// lien journalisé par LoggingEmailService. Le reconstituer depuis la base ou les logs
|
||||
// n'est pas fiable en intégration. On vérifie donc de façon déterministe :
|
||||
// - forgot-password renvoie toujours 200 (non-divulgation, Requirement 6.6) ;
|
||||
// - reset-password avec un jeton bidon renvoie 400 (lien invalide, Requirement 7.4).
|
||||
// Le remplacement effectif du mot de passe via un jeton valide est couvert par les
|
||||
// tests de propriété (P11) au niveau du service, où le jeton en clair est accessible.
|
||||
using var factory = new IsolatedAuthFactory();
|
||||
var client = CreerClient(factory);
|
||||
|
||||
var compte = NouveauCompte();
|
||||
|
||||
// Prérequis : un compte existe.
|
||||
var registerResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/register", new RegisterDto(compte.Email, compte.MotDePasse));
|
||||
registerResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// forgot-password renvoie 200 (email connu).
|
||||
var forgotResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/forgot-password", new ForgotPasswordDto(compte.Email));
|
||||
forgotResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// reset-password avec un token bidon => 400 (lien invalide).
|
||||
var resetResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/reset-password", new ResetPasswordDto("token-bidon-inexistant", "NouveauMotDePasse123!"));
|
||||
resetResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inscrit puis connecte un nouveau compte et retourne le jeton JWT obtenu.
|
||||
/// </summary>
|
||||
private static async Task<string> RegisterEtLoginAsync(HttpClient client)
|
||||
{
|
||||
var compte = NouveauCompte();
|
||||
|
||||
var registerResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/register", new RegisterDto(compte.Email, compte.MotDePasse));
|
||||
registerResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login", new LoginDto(compte.Email, compte.MotDePasse));
|
||||
loginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var loginResult = await loginResponse.Content.ReadFromJsonAsync<LoginResultDto>();
|
||||
loginResult.Should().NotBeNull();
|
||||
loginResult!.Token.Should().NotBeNullOrWhiteSpace();
|
||||
return loginResult.Token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la logique de sélection en DI de l'implémentation d'<see cref="IEmailService"/>,
|
||||
/// telle que décrite dans <c>Program.cs</c> :
|
||||
/// Smtp:Host vide => LoggingEmailService (repli journalisation, Exigence 6.5)
|
||||
/// Smtp:Host renseigné => SmtpEmailService (MailKit, Exigence 6.4)
|
||||
///
|
||||
/// On reconstruit un <see cref="ServiceCollection"/> minimal appliquant la même condition
|
||||
/// (<c>string.IsNullOrWhiteSpace(smtpOptions.Host)</c>) puis on résout le service pour
|
||||
/// confirmer le type concret retenu, sans démarrer l'hôte complet (évite l'accès à la base réelle).
|
||||
/// </summary>
|
||||
public class AuthServiceEmailSelectionTests
|
||||
{
|
||||
private static IEmailService ResoudreEmailService(string host)
|
||||
{
|
||||
var smtpOptions = new SmtpOptions { Host = host };
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.Configure<SmtpOptions>(o => o.Host = host);
|
||||
|
||||
// Réplique exacte de la condition de Program.cs (section « Sélection de l'implémentation Email »).
|
||||
if (string.IsNullOrWhiteSpace(smtpOptions.Host))
|
||||
services.AddScoped<IEmailService, LoggingEmailService>();
|
||||
else
|
||||
services.AddScoped<IEmailService, SmtpEmailService>();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IEmailService>();
|
||||
}
|
||||
|
||||
// Feature: authentification, sélection DI email (5.3) : Host vide => LoggingEmailService.
|
||||
// Validates: Requirements 6.5
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Host_vide_selectionne_LoggingEmailService(string host)
|
||||
{
|
||||
var service = ResoudreEmailService(host);
|
||||
service.Should().BeOfType<LoggingEmailService>();
|
||||
}
|
||||
|
||||
// Feature: authentification, sélection DI email (5.3) : Host renseigné => SmtpEmailService.
|
||||
// Validates: Requirements 6.4
|
||||
[Fact]
|
||||
public void Host_renseigne_selectionne_SmtpEmailService()
|
||||
{
|
||||
var service = ResoudreEmailService("smtp.example.com");
|
||||
service.Should().BeOfType<SmtpEmailService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AuthTestHarness"/> avec une base SQLite
|
||||
/// en mémoire recréée à chaque cas (isolation), un vrai PasswordHasher / JwtService et un
|
||||
/// IEmailService mocké.
|
||||
/// </summary>
|
||||
public class AuthServiceProperties
|
||||
{
|
||||
// --- Générateurs contraints à l'espace d'entrée pertinent ------------------
|
||||
|
||||
/// <summary>Emails syntaxiquement valides (locale@domaine.tld).</summary>
|
||||
private static Arbitrary<string> EmailArb()
|
||||
{
|
||||
var lettre = Gen.Elements("abcdefghijklmnopqrstuvwxyz0123456789".ToCharArray());
|
||||
Gen<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>Mots de passe d'au moins 8 caractères (imprimables ASCII).</summary>
|
||||
private static Arbitrary<string> 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<string, string, Task> 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<Task> deuxieme = () =>
|
||||
h.Service.RegisterAsync(new RegisterDto(email2, mdp), CancellationToken.None);
|
||||
|
||||
deuxieme.Should().ThrowAsync<CompteDejaExistantException>().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<Task> mauvaisMdp = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, mdp + "X"), CancellationToken.None);
|
||||
await mauvaisMdp.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
|
||||
// Email inconnu => rejet.
|
||||
Func<Task> emailInconnu = () =>
|
||||
h.Service.LoginAsync(new LoginDto("inconnu-" + email, mdp), CancellationToken.None);
|
||||
await emailInconnu.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
});
|
||||
}
|
||||
|
||||
// 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<Task> 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<Task> 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<Task> nouveau = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, nouveauMdp), CancellationToken.None);
|
||||
nouveau.Should().NotThrowAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Login KO avec l'ancien mot de passe.
|
||||
Func<Task> ancien = () =>
|
||||
h.Service.LoginAsync(new LoginDto(email, ancienMdp), CancellationToken.None);
|
||||
ancien.Should().ThrowAsync<IdentifiantsInvalidesException>().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<Task> deuxieme = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveau), CancellationToken.None);
|
||||
deuxieme.Should().ThrowAsync<LienResetInvalideException>().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<Task> inconnu = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto("token-inexistant", nouveauMdp), CancellationToken.None);
|
||||
inconnu.Should().ThrowAsync<LienResetInvalideException>().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<Task> expire = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None);
|
||||
expire.Should().ThrowAsync<LienResetExpireException>().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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SchotzIadKmTracker.Api.Models.DTOs;
|
||||
using SchotzIadKmTracker.Api.Services.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests unitaires / d'exemples du flux d'authentification (exemples ciblés
|
||||
/// complémentaires aux tests de propriétés) : login, non-divulgation,
|
||||
/// réinitialisation valide et cas d'erreur.
|
||||
/// </summary>
|
||||
public class AuthServiceTests
|
||||
{
|
||||
private const string Email = "utilisateur@example.com";
|
||||
private const string MotDePasse = "MotDePasse123";
|
||||
|
||||
// Feature: authentification, Property 8 (exemple) : login OK puis rejet mauvais mdp / email inconnu.
|
||||
// Validates: Requirements 2.1, 2.2
|
||||
[Fact]
|
||||
public async Task Login_avec_bons_identifiants_emet_un_jeton_validable()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
var resultat = await h.Service.LoginAsync(new LoginDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
resultat.Token.Should().NotBeNullOrWhiteSpace();
|
||||
JwtTestUtils.EstValide(resultat.Token, AuthTestHarness.SecretKey).Should().BeTrue();
|
||||
resultat.Expiration.Should().BeAfter(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_avec_mauvais_mot_de_passe_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
Func<Task> acte = () => h.Service.LoginAsync(new LoginDto(Email, "MauvaisMotDePasse"), CancellationToken.None);
|
||||
|
||||
await acte.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_avec_email_inconnu_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
Func<Task> acte = () => h.Service.LoginAsync(new LoginDto("autre@example.com", MotDePasse), CancellationToken.None);
|
||||
|
||||
await acte.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 9 (exemple) : forgot-password réponse identique connu/inconnu.
|
||||
// Validates: Requirements 6.6
|
||||
[Fact]
|
||||
public async Task ForgotPassword_email_inconnu_ne_cree_aucun_token_et_n_appelle_pas_email()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto("inconnu@example.com"), CancellationToken.None);
|
||||
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(0);
|
||||
h.NbAppelsEmail.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgotPassword_email_connu_cree_un_token_et_appelle_email_une_fois()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
|
||||
(await h.Context.ResetPasswordTokens.CountAsync()).Should().Be(1);
|
||||
h.NbAppelsEmail.Should().Be(1);
|
||||
h.DernierLienReset.Should().Contain("/reset-password?token=");
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 11 (exemple) : reset valide remplace le mot de passe.
|
||||
// Validates: Requirements 7.1
|
||||
[Fact]
|
||||
public async Task ResetPassword_valide_permet_login_avec_nouveau_mdp()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
const string nouveauMdp = "NouveauMdp456";
|
||||
|
||||
await h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, nouveauMdp), CancellationToken.None);
|
||||
|
||||
var ok = await h.Service.LoginAsync(new LoginDto(Email, nouveauMdp), CancellationToken.None);
|
||||
ok.Token.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
Func<Task> ancien = () => h.Service.LoginAsync(new LoginDto(Email, MotDePasse), CancellationToken.None);
|
||||
await ancien.Should().ThrowAsync<IdentifiantsInvalidesException>();
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 12 (exemple) : usage unique du token de reset.
|
||||
// Validates: Requirements 7.2, 7.4
|
||||
[Fact]
|
||||
public async Task ResetPassword_deuxieme_usage_du_meme_token_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
|
||||
await h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, "PremierMdp123"), CancellationToken.None);
|
||||
h.Context.ResetPasswordTokens.Single().UsedAt.Should().NotBeNull();
|
||||
|
||||
Func<Task> reuse = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, "DeuxiemeMdp123"), CancellationToken.None);
|
||||
await reuse.Should().ThrowAsync<LienResetInvalideException>();
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 13 (exemples) : token inconnu / expiré rejetés, mdp inchangé.
|
||||
// Validates: Requirements 7.3, 7.4
|
||||
[Fact]
|
||||
public async Task ResetPassword_token_inconnu_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
var hashInitial = h.Context.Comptes.Single().MotDePasseHash;
|
||||
|
||||
Func<Task> acte = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto("inexistant", "PeuImporte123"), CancellationToken.None);
|
||||
await acte.Should().ThrowAsync<LienResetInvalideException>();
|
||||
|
||||
h.Context.Comptes.Single().MotDePasseHash.Should().Be(hashInitial);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetPassword_token_expire_est_rejete()
|
||||
{
|
||||
using var h = new AuthTestHarness();
|
||||
await h.Service.RegisterAsync(new RegisterDto(Email, MotDePasse), CancellationToken.None);
|
||||
await h.Service.ForgotPasswordAsync(new ForgotPasswordDto(Email), CancellationToken.None);
|
||||
var hashInitial = h.Context.Comptes.Single().MotDePasseHash;
|
||||
|
||||
var tokenClair = AuthTestHarness.ExtraireToken(h.DernierLienReset!);
|
||||
var token = h.Context.ResetPasswordTokens.Single();
|
||||
token.Expiration = DateTime.UtcNow.AddMinutes(-1);
|
||||
await h.Context.SaveChangesAsync();
|
||||
|
||||
Func<Task> acte = () =>
|
||||
h.Service.ResetPasswordAsync(new ResetPasswordDto(tokenClair, "PeuImporte123"), CancellationToken.None);
|
||||
await acte.Should().ThrowAsync<LienResetExpireException>();
|
||||
|
||||
h.Context.Entry(h.Context.Comptes.Single()).Reload();
|
||||
h.Context.Comptes.Single().MotDePasseHash.Should().Be(hashInitial);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Data;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Banc d'essai pour tester <see cref="AuthService"/> en isolation.
|
||||
///
|
||||
/// Utilise un <see cref="AppDbContext"/> adossé à une base SQLite EN MÉMOIRE
|
||||
/// ("Data Source=:memory:") dont la connexion est maintenue ouverte pour la
|
||||
/// durée du harnais (la base disparaît à la fermeture de la connexion).
|
||||
/// On emploie de vrais <see cref="PasswordHasher{Compte}"/> et <see cref="JwtService"/>
|
||||
/// (JWT signé HS256 avec une clé >= 32 octets), et on mocke <see cref="IEmailService"/>
|
||||
/// (Moq) afin de capturer le lien de réinitialisation transmis.
|
||||
///
|
||||
/// Chaque instance crée une base fraîche : l'isolation entre itérations de
|
||||
/// propriété est obtenue en instanciant un nouveau harnais par cas.
|
||||
/// </summary>
|
||||
public sealed class AuthTestHarness : IDisposable
|
||||
{
|
||||
// Clé secrète >= 32 octets (exigée par HMAC-SHA256).
|
||||
public const string SecretKey = "cle-secrete-de-test-suffisamment-longue-1234567890";
|
||||
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public AppDbContext Context { get; }
|
||||
public AuthService Service { get; }
|
||||
public Mock<IEmailService> EmailMock { get; }
|
||||
public JwtService Jwt { get; }
|
||||
public PasswordHasher<Compte> Hasher { get; }
|
||||
public JwtOptions JwtOptions { get; }
|
||||
public AuthOptions AuthOptions { get; }
|
||||
|
||||
/// <summary>Dernier lien de réinitialisation capturé via le mock IEmailService.</summary>
|
||||
public string? DernierLienReset { get; private set; }
|
||||
|
||||
/// <summary>Nombre d'appels à EnvoyerLienResetAsync capturés.</summary>
|
||||
public int NbAppelsEmail { get; private set; }
|
||||
|
||||
public AuthTestHarness(int jwtExpirationMinutes = 720, int resetTokenLifetimeMinutes = 60)
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open(); // maintenue ouverte : garde la base en vie
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
Context = new AppDbContext(options);
|
||||
Context.Database.EnsureCreated();
|
||||
|
||||
Hasher = new PasswordHasher<Compte>();
|
||||
|
||||
JwtOptions = new JwtOptions
|
||||
{
|
||||
SecretKey = SecretKey,
|
||||
Issuer = "SchotzIadKmTracker",
|
||||
Audience = "SchotzIadKmTracker",
|
||||
ExpirationMinutes = jwtExpirationMinutes,
|
||||
};
|
||||
|
||||
AuthOptions = new AuthOptions
|
||||
{
|
||||
ResetTokenLifetimeMinutes = resetTokenLifetimeMinutes,
|
||||
FrontendBaseUrl = "http://localhost:8080",
|
||||
};
|
||||
|
||||
Jwt = new JwtService(Options.Create(JwtOptions));
|
||||
|
||||
EmailMock = new Mock<IEmailService>();
|
||||
EmailMock
|
||||
.Setup(e => e.EnvoyerLienResetAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string, CancellationToken>((_, lien, _) =>
|
||||
{
|
||||
DernierLienReset = lien;
|
||||
NbAppelsEmail++;
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Service = new AuthService(
|
||||
Context,
|
||||
Hasher,
|
||||
Jwt,
|
||||
EmailMock.Object,
|
||||
Options.Create(AuthOptions),
|
||||
Options.Create(JwtOptions),
|
||||
NullLogger<AuthService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>Extrait la valeur du paramètre "token" de la query string d'un lien de reset.</summary>
|
||||
public static string ExtraireToken(string lienReset)
|
||||
{
|
||||
var uri = new Uri(lienReset);
|
||||
var query = uri.Query.TrimStart('?');
|
||||
foreach (var paire in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var kv = paire.Split('=', 2);
|
||||
if (kv.Length == 2 && kv[0] == "token")
|
||||
{
|
||||
return Uri.UnescapeDataString(kv[1]);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Aucun paramètre 'token' dans le lien : {lienReset}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Context.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
using SchotzIadKmTracker.Api.Services;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Propriétés de correction du service JWT (feature authentification).
|
||||
/// Couvre l'émission/validation des jetons (Property 2), le rejet des jetons expirés
|
||||
/// (Property 3) et le rejet des signatures invalides (Property 4).
|
||||
/// </summary>
|
||||
public class JwtServiceProperties
|
||||
{
|
||||
// Clé de signature >= 32 octets requise pour HS256 (sinon SecurityTokenEncryptionKeySizeException).
|
||||
private const string CleSecrete = "clef-secrete-de-test-suffisamment-longue-0123456789";
|
||||
private const string Issuer = "SchotzIadKmTracker";
|
||||
private const string Audience = "SchotzIadKmTracker";
|
||||
|
||||
/// <summary>Construit un JwtService réel avec la durée d'expiration fournie.</summary>
|
||||
private static JwtService CreerService(int expirationMinutes)
|
||||
{
|
||||
var options = Options.Create(new JwtOptions
|
||||
{
|
||||
SecretKey = CleSecrete,
|
||||
Issuer = Issuer,
|
||||
Audience = Audience,
|
||||
ExpirationMinutes = expirationMinutes
|
||||
});
|
||||
return new JwtService(options);
|
||||
}
|
||||
|
||||
/// <summary>Paramètres de validation alignés sur ceux du pipeline (Program.cs).</summary>
|
||||
private static TokenValidationParameters CreerParametresValidation(string cleSecrete = CleSecrete)
|
||||
=> new()
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidAudience = Audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cleSecrete)),
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
/// <summary>Générateur d'ExpirationMinutes borné (1..100000) pour éviter les valeurs absurdes.</summary>
|
||||
private static Arbitrary<int> ExpirationMinutesArb()
|
||||
=> Gen.Choose(1, 100_000).ToArbitrary();
|
||||
|
||||
/// <summary>Générateur d'emails non vides (bornés) pour les comptes de test.</summary>
|
||||
private static Gen<string> EmailGen()
|
||||
=> from local in Gen.Elements("alice", "bob", "carol", "utilisateur", "compte.unique")
|
||||
from domaine in Gen.Elements("example.com", "test.fr", "mail.org")
|
||||
select $"{local}@{domaine}";
|
||||
|
||||
// Feature: authentification, Property 2: Pour toute valeur d'ExpirationMinutes positive et tout
|
||||
// compte, le jeton émis est validable avec les paramètres de validation, préserve les claims
|
||||
// sub et email, et son exp vaut iat + ExpirationMinutes (à la tolérance d'horloge près).
|
||||
// Validates: Requirements 2.1, 2.4, 5.1, 5.4, 5.5
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Le_jeton_emis_est_validable_et_preserve_les_claims()
|
||||
{
|
||||
var arb = Arb.From(
|
||||
from expiration in ExpirationMinutesArb().Generator
|
||||
from id in Gen.Choose(1, 1_000_000)
|
||||
from email in EmailGen()
|
||||
select (expiration, id, email));
|
||||
|
||||
return Prop.ForAll(arb, tuple =>
|
||||
{
|
||||
var (expirationMinutes, id, email) = tuple;
|
||||
var service = CreerService(expirationMinutes);
|
||||
var compte = new Compte { Id = id, Email = email };
|
||||
|
||||
var jeton = service.GenererJeton(compte);
|
||||
|
||||
// Ne pas remapper les noms de claims entrants (sub reste "sub", email reste "email").
|
||||
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||
handler.ValidateToken(jeton, CreerParametresValidation(), out var validated);
|
||||
|
||||
var jwt = (JwtSecurityToken)validated;
|
||||
|
||||
// Claims sub et email préservés (lecture sur le jeton validé, noms d'origine).
|
||||
var sub = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value;
|
||||
var emailClaim = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Email)?.Value;
|
||||
|
||||
sub.Should().Be(id.ToString());
|
||||
emailClaim.Should().Be(email);
|
||||
|
||||
// exp ≈ iat + ExpirationMinutes (tolérance de 5 secondes pour l'écart d'exécution).
|
||||
var ecart = jwt.ValidTo - jwt.ValidFrom;
|
||||
ecart.Should().BeCloseTo(TimeSpan.FromMinutes(expirationMinutes), TimeSpan.FromSeconds(5));
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 3: Pour tout jeton dont la date d'expiration est antérieure
|
||||
// à l'instant courant, la validation échoue (SecurityTokenExpiredException).
|
||||
// Validates: Requirements 4.3, 5.2
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Un_jeton_expire_est_rejete()
|
||||
{
|
||||
// On fabrique un jeton déjà expiré via le handler avec la même clé.
|
||||
// minutesEcoulees > ClockSkew (30 s) pour dépasser la tolérance d'horloge.
|
||||
var arb = Gen.Choose(2, 10_000).ToArbitrary();
|
||||
|
||||
return Prop.ForAll(arb, minutesEcoulees =>
|
||||
{
|
||||
var maintenant = DateTime.UtcNow;
|
||||
var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(CleSecrete));
|
||||
var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var jetonExpire = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, "1") },
|
||||
notBefore: maintenant.AddMinutes(-minutesEcoulees - 1),
|
||||
expires: maintenant.AddMinutes(-minutesEcoulees),
|
||||
signingCredentials: credentials);
|
||||
|
||||
var jeton = new JwtSecurityTokenHandler().WriteToken(jetonExpire);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
Action validation = () => handler.ValidateToken(jeton, CreerParametresValidation(), out _);
|
||||
|
||||
validation.Should().Throw<SecurityTokenExpiredException>();
|
||||
});
|
||||
}
|
||||
|
||||
// Feature: authentification, Property 4: Pour tout jeton signé avec une clé différente de la clé
|
||||
// de validation, ou dont la charge utile a été altérée, la validation échoue.
|
||||
// Validates: Requirements 5.3, 4.2
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Une_signature_invalide_est_rejetee()
|
||||
{
|
||||
// Deux cas : (a) jeton signé avec une autre clé, (b) charge utile altérée.
|
||||
var arb = Arb.From(
|
||||
from id in Gen.Choose(1, 1_000_000)
|
||||
from email in EmailGen()
|
||||
from altererCharge in Gen.Elements(true, false)
|
||||
select (id, email, altererCharge));
|
||||
|
||||
return Prop.ForAll(arb, tuple =>
|
||||
{
|
||||
var (id, email, altererCharge) = tuple;
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parametres = CreerParametresValidation();
|
||||
|
||||
string jeton;
|
||||
if (altererCharge)
|
||||
{
|
||||
// Jeton valide dont on altère la charge utile (segment central).
|
||||
var service = CreerService(720);
|
||||
var valide = service.GenererJeton(new Compte { Id = id, Email = email });
|
||||
var segments = valide.Split('.');
|
||||
// Inverser deux caractères de la charge (payload) pour casser la signature.
|
||||
var charge = segments[1].ToCharArray();
|
||||
if (charge.Length >= 2)
|
||||
{
|
||||
(charge[0], charge[1]) = (charge[1], charge[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
charge = (segments[1] + "X").ToCharArray();
|
||||
}
|
||||
segments[1] = new string(charge);
|
||||
jeton = string.Join('.', segments);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Jeton signé avec une clé DIFFÉRENTE (>= 32 octets) de celle de validation.
|
||||
const string autreCle = "AUTRE-clef-secrete-differente-mais-assez-longue-9876543210";
|
||||
var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(autreCle));
|
||||
var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256);
|
||||
var maintenant = DateTime.UtcNow;
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, email)
|
||||
},
|
||||
notBefore: maintenant,
|
||||
expires: maintenant.AddMinutes(720),
|
||||
signingCredentials: credentials);
|
||||
jeton = new JwtSecurityTokenHandler().WriteToken(jwt);
|
||||
}
|
||||
|
||||
// La validation doit échouer : soit une exception de signature (SecurityTokenException,
|
||||
// p. ex. SecurityTokenInvalidSignatureException / SecurityTokenSignatureKeyNotFoundException),
|
||||
// soit un rejet au décodage lorsque la charge altérée n'est plus décodable (ArgumentException).
|
||||
Exception? levee = null;
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(jeton, parametres, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
levee = ex;
|
||||
}
|
||||
|
||||
levee.Should().NotBeNull("un jeton à signature invalide ou charge altérée doit être rejeté");
|
||||
levee.Should().Match(e => e is SecurityTokenException || e is ArgumentException);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Utilitaires de validation de jetons JWT pour les tests, alignés sur les
|
||||
/// paramètres de validation de <c>Program.cs</c> (issuer / audience / clé de
|
||||
/// signature / durée de vie).
|
||||
/// </summary>
|
||||
internal static class JwtTestUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// Valide un jeton HS256 avec la clé fournie. Retourne <c>true</c> si le jeton
|
||||
/// est signé par cette clé, non expiré et porte l'issuer/audience attendus.
|
||||
/// </summary>
|
||||
public static bool EstValide(
|
||||
string token,
|
||||
string secretKey,
|
||||
string issuer = "SchotzIadKmTracker",
|
||||
string audience = "SchotzIadKmTracker")
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = issuer,
|
||||
ValidAudience = audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(token, parameters, out _);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SchotzIadKmTracker.Api.Configuration;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests d'exemple vérifiant la liaison des options d'authentification depuis une configuration
|
||||
/// in-memory, ainsi que les valeurs par défaut attendues (feature authentification, tâche 2.2).
|
||||
/// </summary>
|
||||
public class OptionsBindingTests
|
||||
{
|
||||
private static IConfiguration ConstruireConfiguration(IEnumerable<KeyValuePair<string, string?>> valeurs)
|
||||
=> new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(valeurs)
|
||||
.Build();
|
||||
|
||||
// Validates: Requirements 5.5
|
||||
[Fact]
|
||||
public void JwtOptions_se_lie_depuis_la_configuration_et_applique_les_defauts()
|
||||
{
|
||||
// Seul SecretKey est fourni : Issuer/Audience/ExpirationMinutes doivent prendre leurs défauts.
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:SecretKey"] = "clef-secrete-de-test-suffisamment-longue-0123456789"
|
||||
});
|
||||
|
||||
var options = new JwtOptions();
|
||||
config.GetSection(JwtOptions.SectionName).Bind(options);
|
||||
|
||||
options.SecretKey.Should().Be("clef-secrete-de-test-suffisamment-longue-0123456789");
|
||||
options.Issuer.Should().Be("SchotzIadKmTracker");
|
||||
options.Audience.Should().Be("SchotzIadKmTracker");
|
||||
options.ExpirationMinutes.Should().Be(720);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtOptions_prend_les_valeurs_fournies_par_la_configuration()
|
||||
{
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:SecretKey"] = "une-autre-clef-de-test-tres-longue-abcdefghijklmnop",
|
||||
["Jwt:Issuer"] = "MonIssuer",
|
||||
["Jwt:Audience"] = "MonAudience",
|
||||
["Jwt:ExpirationMinutes"] = "60"
|
||||
});
|
||||
|
||||
var options = new JwtOptions();
|
||||
config.GetSection(JwtOptions.SectionName).Bind(options);
|
||||
|
||||
options.Issuer.Should().Be("MonIssuer");
|
||||
options.Audience.Should().Be("MonAudience");
|
||||
options.ExpirationMinutes.Should().Be(60);
|
||||
}
|
||||
|
||||
// Validates: Requirements 6.3
|
||||
[Fact]
|
||||
public void AuthOptions_se_lie_depuis_la_configuration_et_applique_les_defauts()
|
||||
{
|
||||
// Configuration vide : les défauts doivent s'appliquer.
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>());
|
||||
|
||||
var options = new AuthOptions();
|
||||
config.GetSection(AuthOptions.SectionName).Bind(options);
|
||||
|
||||
options.ResetTokenLifetimeMinutes.Should().Be(60);
|
||||
options.FrontendBaseUrl.Should().Be("http://localhost:8080");
|
||||
}
|
||||
|
||||
// Validates: Requirements 6.7
|
||||
[Fact]
|
||||
public void SmtpOptions_se_lie_depuis_la_configuration_et_applique_les_defauts()
|
||||
{
|
||||
var config = ConstruireConfiguration(new Dictionary<string, string?>());
|
||||
|
||||
var options = new SmtpOptions();
|
||||
config.GetSection(SmtpOptions.SectionName).Bind(options);
|
||||
|
||||
options.Host.Should().BeEmpty();
|
||||
options.Port.Should().Be(587);
|
||||
options.User.Should().BeEmpty();
|
||||
options.Password.Should().BeEmpty();
|
||||
options.From.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using FluentAssertions;
|
||||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using SchotzIadKmTracker.Api.Models.Entities;
|
||||
|
||||
namespace SchotzIadKmTracker.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Propriété de correction du hachage de mot de passe (feature authentification).
|
||||
/// Utilise le PasswordHasher<Compte> réel d'ASP.NET Core Identity.
|
||||
/// </summary>
|
||||
public class PasswordHasherProperties
|
||||
{
|
||||
// Feature: authentification, Property 1: Pour tout mot de passe non vide, le hachage produit une
|
||||
// valeur différente du clair, VerifyHashedPassword retourne Success, et deux hachages du même
|
||||
// mot de passe diffèrent (présence de sel).
|
||||
// Validates: Requirements 1.5, 8.1, 8.3
|
||||
[Property(MaxTest = 100)]
|
||||
public Property Le_hachage_est_verifiable_et_sale()
|
||||
{
|
||||
// Générateur de mots de passe non vides et bornés (1..64 caractères imprimables).
|
||||
var arb = Arb.From(
|
||||
Gen.Choose(1, 64)
|
||||
.SelectMany(longueur =>
|
||||
Gen.ArrayOf(longueur, Gen.Choose(33, 126).Select(c => (char)c))
|
||||
.Select(chars => new string(chars))));
|
||||
|
||||
return Prop.ForAll(arb, motDePasse =>
|
||||
{
|
||||
var hasher = new PasswordHasher<Compte>();
|
||||
var compte = new Compte { Id = 1, Email = "test@example.com" };
|
||||
|
||||
var hash1 = hasher.HashPassword(compte, motDePasse);
|
||||
var hash2 = hasher.HashPassword(compte, motDePasse);
|
||||
|
||||
// Le hash diffère du clair.
|
||||
hash1.Should().NotBe(motDePasse);
|
||||
|
||||
// La vérification réussit.
|
||||
hasher.VerifyHashedPassword(compte, hash1, motDePasse)
|
||||
.Should().BeOneOf(PasswordVerificationResult.Success,
|
||||
PasswordVerificationResult.SuccessRehashNeeded);
|
||||
|
||||
// Deux hachages du même mot de passe diffèrent (sel aléatoire).
|
||||
hash1.Should().NotBe(hash2);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user