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