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; /// /// Tests d'intégration de l'authentification (feature authentification), exécutés sur /// l'hôte réel via (Program expose /// public partial class Program {}). /// /// 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 dans un bloc using : /// base neuve garantie et nettoyage du fichier temporaire. /// 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"; /// /// 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. /// private sealed class IsolatedAuthFactory : WebApplicationFactory { 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. } } } } /// /// Crée un client HTTP sans redirection auto ni cookies : on teste les codes de statut bruts. /// 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(); 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); } /// /// Inscrit puis connecte un nouveau compte et retourne le jeton JWT obtenu. /// private static async Task 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(); loginResult.Should().NotBeNull(); loginResult!.Token.Should().NotBeNullOrWhiteSpace(); return loginResult.Token; } }