Files
SchotzIAD/backend/SchotzIadKmTracker.Tests/Auth/AuthServiceTests.cs
T
MadJax dd36bbc425 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).
2026-09-14 09:07:08 +02:00

164 lines
7.0 KiB
C#

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