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;
///
/// 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).
///
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";
/// Construit un JwtService réel avec la durée d'expiration fournie.
private static JwtService CreerService(int expirationMinutes)
{
var options = Options.Create(new JwtOptions
{
SecretKey = CleSecrete,
Issuer = Issuer,
Audience = Audience,
ExpirationMinutes = expirationMinutes
});
return new JwtService(options);
}
/// Paramètres de validation alignés sur ceux du pipeline (Program.cs).
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)
};
/// Générateur d'ExpirationMinutes borné (1..100000) pour éviter les valeurs absurdes.
private static Arbitrary ExpirationMinutesArb()
=> Gen.Choose(1, 100_000).ToArbitrary();
/// Générateur d'emails non vides (bornés) pour les comptes de test.
private static Gen 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();
});
}
// 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);
});
}
}