Files
SchotzIAD/backend/SchotzIadKmTracker.Tests/Auth/AuthIntegrationTests.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

243 lines
11 KiB
C#

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