diff --git a/.editorconfig b/.editorconfig index da3b2dd..8524a5b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -484,18 +484,15 @@ dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter #### Visual Studio Specific Rules #### -# CA1822: Mark members as static -dotnet_diagnostic.CA1822.severity = none - # IDE0004: Cast is redundant dotnet_diagnostic.IDE0004.severity = error -# IDE0058: Expression value is never used -dotnet_diagnostic.IDE0058.severity = none - # IDE0011: Add braces to 'if'/'else' statement dotnet_diagnostic.IDE0011.severity = none +# IDE0058: Expression value is never used +dotnet_diagnostic.IDE0058.severity = none + # Use primary constructor dotnet_diagnostic.IDE0290.severity = none diff --git a/ArtwiseAPI/ArtwiseAPI.csproj b/ArtwiseAPI/ArtwiseAPI.csproj index 5fc6789..f27ced7 100644 --- a/ArtwiseAPI/ArtwiseAPI.csproj +++ b/ArtwiseAPI/ArtwiseAPI.csproj @@ -1,12 +1,14 @@ - 1.0.0.0 - - + + + + + \ No newline at end of file diff --git a/ArtwiseAPI/ArtwiseAPI.http b/ArtwiseAPI/ArtwiseAPI.http deleted file mode 100644 index de4f14d..0000000 --- a/ArtwiseAPI/ArtwiseAPI.http +++ /dev/null @@ -1,6 +0,0 @@ -@ArtwiseAPI_HostAddress = http://localhost:5097 - -GET {{ArtwiseAPI_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/ArtwiseAPI/Common/ApiConstants.cs b/ArtwiseAPI/Common/ApiConstants.cs new file mode 100644 index 0000000..fdf47da --- /dev/null +++ b/ArtwiseAPI/Common/ApiConstants.cs @@ -0,0 +1,22 @@ +namespace ArtwiseAPI.Common; + +/// +/// Defines the API's global constants. +/// +public static class ApiConstants +{ + /// + /// Represents the version of the API. + /// + public const string Version = "v1"; + + /// + /// Represents the main path of the API endpoints. + /// + public const string MainEndpoint = $"api/{Version}/[controller]"; + + /// + /// Represents the location of the security key in the "appsettings.json" file. + /// + public const string JwtAppSetting = "Jwt:Key"; +} \ No newline at end of file diff --git a/ArtwiseAPI/Common/ApiStatics.cs b/ArtwiseAPI/Common/ApiStatics.cs new file mode 100644 index 0000000..d4adea0 --- /dev/null +++ b/ArtwiseAPI/Common/ApiStatics.cs @@ -0,0 +1,17 @@ +using System.Text.RegularExpressions; + +namespace ArtwiseAPI.Common; + +/// +/// Defines the API's global properties. +/// +public sealed partial class ApiStatics +{ + /// + /// Regex to match a valid e-mail address. + /// + public static Regex EmailRegex { get; } = GenerateEmailRegex(); + + [GeneratedRegex(@"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", RegexOptions.Compiled)] + private static partial Regex GenerateEmailRegex(); +} \ No newline at end of file diff --git a/ArtwiseAPI/Common/MediaType.cs b/ArtwiseAPI/Common/MediaType.cs new file mode 100644 index 0000000..099e1e0 --- /dev/null +++ b/ArtwiseAPI/Common/MediaType.cs @@ -0,0 +1,22 @@ +namespace ArtwiseAPI.Common; + +/// +/// Defines HTTP media types. +/// +internal static class MediaType +{ + /// + /// Defines a response/request in plain JSON text format. + /// + public const string AppJson = "application/json"; + + /// + /// Defines a response/request in plain JSON text format (old type, still sometimes used for historical reasons). + /// + public const string TextJson = "text/json"; + + /// + /// Defines a response that did not complete successfully. + /// + public const string ProblemJson = "application/problem+json"; +} \ No newline at end of file diff --git a/ArtwiseAPI/Extensions/IServiceCollectionExt.cs b/ArtwiseAPI/Extensions/IServiceCollectionExt.cs new file mode 100644 index 0000000..97c2f5c --- /dev/null +++ b/ArtwiseAPI/Extensions/IServiceCollectionExt.cs @@ -0,0 +1,80 @@ +using ArtwiseDatabase.Enums; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Security.Claims; + +namespace ArtwiseAPI.Extensions; + +/// +/// Defines extension methods for . +/// +internal static class IServiceCollectionExt +{ + /// + /// Adds the default authentication and authorization policies used by the Artwise API. + /// + /// This service collection. + /// The private key to be used for signature validation. + /// to add an authentication button on Swagger, otherwise. + /// This service collection with the policies added. + public static IServiceCollection AddArtwiseAuth(this IServiceCollection serviceCollection, byte[] privateKey, bool addSwaggerAuth = true) + { + if (addSwaggerAuth) + { + // Add Swagger authentication button + serviceCollection.AddSwaggerGen(swaggerOptions => + { + var apiSecurityScheme = new OpenApiSecurityScheme() + { + Name = "Authorization", + Description = "Enter the Bearer Authorization string in the format: `bearer jwt-token-here`", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = JwtBearerDefaults.AuthenticationScheme, + Reference = new OpenApiReference() + { + Id = JwtBearerDefaults.AuthenticationScheme, + Type = ReferenceType.SecurityScheme + } + }; + + swaggerOptions.AddSecurityDefinition(JwtBearerDefaults.AuthenticationScheme, apiSecurityScheme); + + // OpenApiSecurityRequirement is a Dictionary> + // Any scheme other than "oauth2" or "openIdConnect" must contain an empty IList + swaggerOptions.AddSecurityRequirement(new OpenApiSecurityRequirement() { [apiSecurityScheme] = Array.Empty() }); + }); + } + + serviceCollection + .AddAuthorization(authOptions => + { + // Require all users to be authenticated + authOptions.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + + // Add authorization policies + // Based on the name of UserPermissions enums (except "Blocked") + foreach (var permission in Enum.GetValues()) + authOptions.AddPolicy(permission.ToString(), policy => policy.RequireClaim(ClaimTypes.Role, permission.ToString())); + }) + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(jwtOptions => + { + jwtOptions.RequireHttpsMetadata = false; + jwtOptions.SaveToken = true; + jwtOptions.TokenValidationParameters = new TokenValidationParameters() + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(privateKey), + ValidateIssuer = false, + ValidateAudience = false + }; + }); + + return serviceCollection; + } +} \ No newline at end of file diff --git a/ArtwiseAPI/Features/Authentication/Controllers/AuthenticationController.cs b/ArtwiseAPI/Features/Authentication/Controllers/AuthenticationController.cs new file mode 100644 index 0000000..05ff9cf --- /dev/null +++ b/ArtwiseAPI/Features/Authentication/Controllers/AuthenticationController.cs @@ -0,0 +1,49 @@ +using ArtwiseAPI.Common; +using ArtwiseAPI.Features.Authentication.Models; +using ArtwiseAPI.Features.Authentication.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace ArtwiseAPI.Features.Authentication.Controllers; + +/// +/// Controller for user authentication. +/// +[ApiController, Route(ApiConstants.MainEndpoint)] +[Consumes(MediaType.AppJson, MediaType.TextJson)] +public sealed class AuthenticationController : ControllerBase +{ + private readonly AuthenticationService _service; + + /// + /// Controller for user authentication. + /// + /// The service that handles user authentication. + public AuthenticationController(AuthenticationService service) + => _service = service; + + /// + /// Authenticates a user and returns a session token to them. + /// + /// The authentication request. + /// A or a . + [HttpPost, AllowAnonymous] + [ProducesResponseType(typeof(AuthenticationResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task, ProblemHttpResult>> AuthenticateAsync([FromBody] AuthenticationRequest request) + { + try + { + return TypedResults.Ok(await _service.LoginAsync(request)); + } + catch (InvalidOperationException ex) + { + return TypedResults.Problem( + statusCode: StatusCodes.Status404NotFound, + detail: ex.Message + ); + } + } +} \ No newline at end of file diff --git a/ArtwiseAPI/Features/Authentication/Models/AuthenticationRequest.cs b/ArtwiseAPI/Features/Authentication/Models/AuthenticationRequest.cs new file mode 100644 index 0000000..c07fe73 --- /dev/null +++ b/ArtwiseAPI/Features/Authentication/Models/AuthenticationRequest.cs @@ -0,0 +1,22 @@ +using ArtwiseAPI.Common; +using System.ComponentModel.DataAnnotations; + +namespace ArtwiseAPI.Features.Authentication.Models; + +/// +/// Represents a request for user authentication. +/// +/// The e-mail of the user. +/// The password of the user. +public sealed record AuthenticationRequest(string Email, string Password) : IValidatableObject +{ + /// + public IEnumerable Validate(ValidationContext validationContext) + { + if (!ApiStatics.EmailRegex.IsMatch(Email ?? string.Empty)) + yield return new ValidationResult("E-mail is not valid.", [nameof(Email)]); + + if (string.IsNullOrWhiteSpace(Password)) + yield return new ValidationResult("Password cannot be null, empty, or whitespace.", [nameof(Password)]); + } +} \ No newline at end of file diff --git a/ArtwiseAPI/Features/Authentication/Models/AuthenticationResponse.cs b/ArtwiseAPI/Features/Authentication/Models/AuthenticationResponse.cs new file mode 100644 index 0000000..9c01cd0 --- /dev/null +++ b/ArtwiseAPI/Features/Authentication/Models/AuthenticationResponse.cs @@ -0,0 +1,9 @@ +namespace ArtwiseAPI.Features.Authentication.Models; + +/// +/// Represents a response when a user successfully authenticates with the API. +/// +/// The Id of the user. +/// The token the user must use for authenticated requests. +/// The date and time the expires. +public sealed record AuthenticationResponse(Guid Id, string SessionToken, DateTimeOffset ExpiresAt); \ No newline at end of file diff --git a/ArtwiseAPI/Features/Authentication/Services/AuthenticationService.cs b/ArtwiseAPI/Features/Authentication/Services/AuthenticationService.cs new file mode 100644 index 0000000..45a7fe8 --- /dev/null +++ b/ArtwiseAPI/Features/Authentication/Services/AuthenticationService.cs @@ -0,0 +1,105 @@ +using ArtwiseAPI.Common; +using ArtwiseAPI.Features.Authentication.Models; +using ArtwiseDatabase; +using ArtwiseDatabase.Entities; +using ArtwiseDatabase.Enums; +using Kotz.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using BC = BCrypt.Net.BCrypt; + +namespace ArtwiseAPI.Features.Authentication.Services; + +/// +/// Handles authentication and authorization workloads. +/// +[Service(ServiceLifetime.Scoped)] +public sealed class AuthenticationService +{ + private readonly ArtwiseDbContext _db; + private readonly IConfiguration _config; + + /// + /// Handles authentication and authorization workloads. + /// + /// The database context. + /// The IHost configuration. + public AuthenticationService(ArtwiseDbContext db, IConfiguration config) + { + _db = db; + _config = config; + } + + /// + /// Authenticates a user. + /// + /// The controller request. + /// The authentication session token. + /// Occurs when is . + /// Occurs when the e-mail or password in the request are invalid. + public async Task LoginAsync(AuthenticationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var expireAt = DateTimeOffset.UtcNow.AddMonths(3); + var dbUser = await _db.Users.FirstOrDefaultAsync(x => x.Email == request.Email); + + return (dbUser is not null && BC.Verify(request.Password, dbUser.PasswordHash)) + ? new AuthenticationResponse(dbUser.Id, GenerateSessionToken(dbUser, expireAt), expireAt) + : throw new InvalidOperationException("E-mail or password are invalid."); + } + + /// + /// Generates a session token for the specified user. + /// + /// The user to generate the token for. + /// The date and time the token should expire. + /// A session token. + /// + /// Occurs when contains invalid data or when + /// is less than the current time. + /// + public string GenerateSessionToken(UserEntity dbUser, DateTimeOffset expiresAt) + => GenerateSessionToken(dbUser.Email, dbUser.Type, expiresAt); + + /// + /// Generates a session token for the specified e-mail. + /// + /// The e-mail of the user. + /// The type of the user. + /// The date and time the token should expire. + /// A session token. + /// + /// Occurs when is or whitespace + /// or when is less than the current time. + /// + public string GenerateSessionToken(string email, UserType userType, DateTimeOffset expiresAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(email); + + if (expiresAt <= DateTimeOffset.UtcNow) + throw new ArgumentException("Token must expire in the future.", nameof(expiresAt)); + + // Generate the claims (for authorization) + var claims = new Claim[] + { + new(ClaimTypes.Email, email), + new(ClaimTypes.Role, userType.ToString()) + }; + + // Generate the token + var tokenDescriptor = new SecurityTokenDescriptor() + { + SigningCredentials = new(new SymmetricSecurityKey(_config.GetValue(ApiConstants.JwtAppSetting)), SecurityAlgorithms.HmacSha256Signature), + Expires = expiresAt.DateTime, + Subject = new(claims) + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } +} \ No newline at end of file diff --git a/ArtwiseAPI/Models/WeatherForecast.cs b/ArtwiseAPI/Models/WeatherForecast.cs deleted file mode 100644 index d8f932b..0000000 --- a/ArtwiseAPI/Models/WeatherForecast.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace ArtwiseAPI.Models; - -internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} \ No newline at end of file diff --git a/ArtwiseAPI/Program.cs b/ArtwiseAPI/Program.cs index 0444394..c3fde5b 100644 --- a/ArtwiseAPI/Program.cs +++ b/ArtwiseAPI/Program.cs @@ -1,5 +1,8 @@ -using ArtwiseAPI.Models; +using ArtwiseAPI.Common; +using ArtwiseAPI.Extensions; +using ArtwiseAPI.SwaggerDocumentation; using ArtwiseDatabase.Extensions; +using Kotz.DependencyInjection.Extensions; namespace ArtwiseAPI; @@ -11,9 +14,14 @@ private static void Main(string[] args) // Add services to the container. // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(); - builder.Services.AddArtwiseDb(); + builder.Services.AddControllers(); + builder.Services + .AddEndpointsApiExplorer() + .AddSwaggerGen(x => x.OperationFilter()) + .AddRouting() + .RegisterServices() + .AddArtwiseDb() + .AddArtwiseAuth(builder.Configuration.GetValue(ApiConstants.JwtAppSetting)!); var app = builder.Build(); @@ -24,28 +32,12 @@ private static void Main(string[] args) app.UseSwaggerUI(); } - app.UseHttpsRedirection(); - - var summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - app.MapGet("/weatherforecast", () => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - ) - ) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast") - .WithOpenApi(); + app.UseHttpsRedirection() + .UseRouting() + .UseAuthentication() + .UseAuthorization(); + app.MapControllers(); app.Run(); } } \ No newline at end of file diff --git a/ArtwiseAPI/SwaggerDocumentation/SwaggerResponseDocumentationFilter.cs b/ArtwiseAPI/SwaggerDocumentation/SwaggerResponseDocumentationFilter.cs new file mode 100644 index 0000000..c56ddbb --- /dev/null +++ b/ArtwiseAPI/SwaggerDocumentation/SwaggerResponseDocumentationFilter.cs @@ -0,0 +1,36 @@ +using ArtwiseAPI.Common; +using Kotz.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ArtwiseAPI.SwaggerDocumentation; + +/// +/// Adjusts the media types of endpoint responses in Swagger's documentation. +/// +/// Can't use [Produces(...)] attribute from MVC, see https://github.com/dotnet/aspnetcore/issues/39802 +internal sealed class SwaggerResponseDocumentationFilter : IOperationFilter +{ + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + foreach (var operationResponse in operation.Responses) + { + if (operationResponse.Key.StartsWith('2')) + { + // Remove all media types that are not "application/json" or "text/json" from successful responses. + var mediaTypesToRemove = operationResponse.Value.Content.Keys.Where(x => !x.EqualsAny(MediaType.AppJson, MediaType.TextJson)); + + foreach (var toRemove in mediaTypesToRemove) + operationResponse.Value.Content.Remove(toRemove); + } + else if (operationResponse.Key[0].EqualsAny('4', '5')) + { + // Remove all media types from unsuccessful responses and add one for "application/problem+json". + operationResponse.Value.Content.Clear(); + operationResponse.Value.Content.Add(MediaType.ProblemJson, new() { Schema = context.SchemaRepository.Schemas[nameof(ProblemDetails)] }); + } + } + } +} \ No newline at end of file diff --git a/ArtwiseAPI/appsettings.Development.json b/ArtwiseAPI/appsettings.Development.json index 0c208ae..f5978fc 100644 --- a/ArtwiseAPI/appsettings.Development.json +++ b/ArtwiseAPI/appsettings.Development.json @@ -4,5 +4,10 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Jwt": { + "Key": "Ym5sWjlGdDRSRm9MVmIzQllSZk9ZUlNsRGtOeFlyUGlUaEttNVpqNzhmNHhiaDZWczlIcnMxa0w5QWZmSWNtUkZIekcxVA==", + "Issuer": "https://localhost:7016;http://localhost:5016", + "Audience": "https://localhost:7016;http://localhost:5016" } -} +} \ No newline at end of file diff --git a/ArtwiseAPI/appsettings.json b/ArtwiseAPI/appsettings.json index 10f68b8..622aa30 100644 --- a/ArtwiseAPI/appsettings.json +++ b/ArtwiseAPI/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" -} + "AllowedHosts": "*", + "Jwt": { + "Key": "SzJyMGRTZ3pvTkR3NmdEekNtSDFLQ051SVVSbFZVRUtjQzRmOWJzb1hnU1FsYUpGc2tlb2JUR0hMNGdVSlJhVEhlb1FMeg==" + } +} \ No newline at end of file diff --git a/ArtwiseDatabase/Entities/Config/UserEntityTypeConfiguration.cs b/ArtwiseDatabase/Entities/Config/UserEntityTypeConfiguration.cs index 0549eb6..d4e65f8 100644 --- a/ArtwiseDatabase/Entities/Config/UserEntityTypeConfiguration.cs +++ b/ArtwiseDatabase/Entities/Config/UserEntityTypeConfiguration.cs @@ -11,4 +11,4 @@ internal sealed class UserEntityTypeConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) => builder.HasKey(x => x.Id); -} +} \ No newline at end of file diff --git a/ArtwiseDatabase/Migrations/20240324001005_InitialMigration.cs b/ArtwiseDatabase/Migrations/20240324001005_InitialMigration.cs index 410a917..1930cc0 100644 --- a/ArtwiseDatabase/Migrations/20240324001005_InitialMigration.cs +++ b/ArtwiseDatabase/Migrations/20240324001005_InitialMigration.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -123,4 +123,4 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "arts"); } -} +} \ No newline at end of file diff --git a/ArtwiseTests/Abstractions/BaseApiServiceTest.cs b/ArtwiseTests/Abstractions/BaseApiServiceTest.cs new file mode 100644 index 0000000..c0aaf94 --- /dev/null +++ b/ArtwiseTests/Abstractions/BaseApiServiceTest.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace ArtwiseTests.Abstractions; + +/// +/// Provides a base for test classes that need to interact with +/// the API's services in the . +/// +[Collection(nameof(ServicesFixture))] +public abstract class BaseApiServiceTest : IDisposable +{ + /// + /// The service scope. + /// + /// Use this to resolve all services you need. + protected IServiceScope Scope { get; } + + // Test classes are created every single time a test is run, + // so we create a clean test context in the constructor and + // dispose in the Dispose() method. + // See: https://xunit.net/docs/shared-context + public BaseApiServiceTest(ServicesFixture fixture) + => Scope = fixture.ServiceProvider.CreateScope(); + + /// + /// Disposes the API's used to + /// resolve the services in this test class. + /// + public virtual void Dispose() + { + Scope.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/ArtwiseTests/ArtwiseTests.csproj b/ArtwiseTests/ArtwiseTests.csproj index 8d1d8ff..f69569d 100644 --- a/ArtwiseTests/ArtwiseTests.csproj +++ b/ArtwiseTests/ArtwiseTests.csproj @@ -1,29 +1,24 @@ - - - - net8.0 - enable - enable - - false - true - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - + + + net8.0 + enable + enable + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + \ No newline at end of file diff --git a/ArtwiseTests/Common/DefaultDbUser.cs b/ArtwiseTests/Common/DefaultDbUser.cs new file mode 100644 index 0000000..aff53e5 --- /dev/null +++ b/ArtwiseTests/Common/DefaultDbUser.cs @@ -0,0 +1,49 @@ +using ArtwiseDatabase.Entities; +using ArtwiseDatabase.Enums; +using BC = BCrypt.Net.BCrypt; + +namespace ArtwiseTests.Common; + +/// +/// Contains valid properties of a object as constants, +/// so their values can be used on attributes. +/// +internal static class DefaultDbUser +{ + /// + /// Id of the user. + /// + internal static Guid Id = new(GuidString); + + /// + /// The string Id of the user. + /// + internal const string GuidString = "dbb75469-64b2-4272-866d-6e1937bcfa7c"; + + /// + /// E-mail of the user. + /// + internal const string Email = "user@email.com"; + + /// + /// Raw passwords of the user. + /// + internal const string Password = "avocado"; + + /// + /// The type of the user. + /// + internal const UserType Type = UserType.Administrator; + + /// + /// The instance of the user. + /// + internal static UserEntity Instance { get; } = new() + { + Id = Id, + Email = Email, + PasswordHash = BC.HashPassword(Password), + Type = Type, + DateAdded = DateTimeOffset.UtcNow + }; +} \ No newline at end of file diff --git a/ArtwiseTests/Fixtures/ServicesFixture.cs b/ArtwiseTests/Fixtures/ServicesFixture.cs new file mode 100644 index 0000000..335aad0 --- /dev/null +++ b/ArtwiseTests/Fixtures/ServicesFixture.cs @@ -0,0 +1,67 @@ +using ArtwiseAPI.Common; +using ArtwiseAPI.Extensions; +using ArtwiseDatabase; +using ArtwiseDatabase.Extensions; +using Kotz.DependencyInjection.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Reflection; + +namespace ArtwiseTests.Fixtures; + +/// +/// Fixture for the API's . +/// +/// +/// This class creates one service provider before any of the tests are run and +/// disposes of it when the tests are over. The same service provider is shared +/// among all test classes that inherit from +/// or that have the with this class' type +/// name applied to it. +/// +public sealed class ServicesFixture : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + + /// + /// A replica of the API's service provider. + /// + internal IServiceProvider ServiceProvider { get; } + + public ServicesFixture() + { + var builder = WebApplication.CreateBuilder(); + + _serviceProvider = builder.Services // Add the default services some Artwise services may depend on + .AddLogging(x => x.ClearProviders()) // Supress service logging, if any is being used + .RegisterServices(Assembly.LoadFrom("ArtwiseAPI.dll")) // Add Artwise services + .AddArtwiseDb("Data Source=file::memory:?cache=shared;", true) // Initialize an in-memory SQLite database + .AddArtwiseAuth(builder.Configuration.GetValue(ApiConstants.JwtAppSetting)!, false) // Add Artwise authentication and authorization services + .BuildServiceProvider(true); + + ServiceProvider = _serviceProvider; + + // Add one default user that can + // be used on unit tests. + AddDefaultUser(ServiceProvider); + } + + public void Dispose() + => _serviceProvider.Dispose(); + + /// + /// Adds a user to the test database + /// before the tests are run. + /// + /// The IoC container. + private static void AddDefaultUser(IServiceProvider services) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.Users.Add(DefaultDbUser.Instance); + db.SaveChanges(); + } +} \ No newline at end of file diff --git a/ArtwiseTests/Fixtures/ServicesFixtureCollection.cs b/ArtwiseTests/Fixtures/ServicesFixtureCollection.cs new file mode 100644 index 0000000..0d435f9 --- /dev/null +++ b/ArtwiseTests/Fixtures/ServicesFixtureCollection.cs @@ -0,0 +1,12 @@ +namespace ArtwiseTests.Fixtures; + +/// +/// This class has no code, and is never created. Its purpose is simply to be the place +/// to apply [CollectionDefinition] and all the +/// interfaces. +/// +/// See: https://stackoverflow.com/questions/12976319/xunit-net-global-setup-teardown +[CollectionDefinition(nameof(ServicesFixture))] +public sealed class ServicesFixtureCollection : ICollectionFixture +{ +} \ No newline at end of file diff --git a/ArtwiseTests/GlobalUsings.cs b/ArtwiseTests/GlobalUsings.cs index 8c927eb..9ab49f7 100644 --- a/ArtwiseTests/GlobalUsings.cs +++ b/ArtwiseTests/GlobalUsings.cs @@ -1 +1,4 @@ +global using ArtwiseTests.Abstractions; +global using ArtwiseTests.Common; +global using ArtwiseTests.Fixtures; global using Xunit; \ No newline at end of file diff --git a/ArtwiseTests/ServiceTests/AuthenticationServiceTests.cs b/ArtwiseTests/ServiceTests/AuthenticationServiceTests.cs new file mode 100644 index 0000000..6cae245 --- /dev/null +++ b/ArtwiseTests/ServiceTests/AuthenticationServiceTests.cs @@ -0,0 +1,62 @@ +using ArtwiseAPI.Features.Authentication.Services; +using ArtwiseDatabase.Enums; +using Microsoft.Extensions.DependencyInjection; + +namespace ArtwiseTests.ServiceTests; + +public sealed class AuthenticationServiceTests : BaseApiServiceTest +{ + private readonly AuthenticationService _service; + + public AuthenticationServiceTests(ServicesFixture fixture) : base(fixture) + => _service = base.Scope.ServiceProvider.GetRequiredService(); + + [Fact] + internal async Task LoginTestAsync() + { + // Use the service + var response = await _service.LoginAsync(new(DefaultDbUser.Email, DefaultDbUser.Password)); + + // Test the result + Assert.Equal(DefaultDbUser.Id, response.Id); + Assert.NotEmpty(response.SessionToken); + } + + [Fact] + internal void LoginEmailNotFoundTest() + => Assert.ThrowsAsync(() => _service.LoginAsync(new("doesnt@exist.com", DefaultDbUser.Password))); + + + [Fact] + internal void LoginWrongPasswordTest() + => Assert.ThrowsAsync(() => _service.LoginAsync(new(DefaultDbUser.Email, DefaultDbUser.Password + "1"))); + + [Fact] + internal void GenerateSessionTokenUserTest() + { + var token = _service.GenerateSessionToken(DefaultDbUser.Instance, DateTime.UtcNow.AddDays(1)); + Assert.NotEmpty(token); + } + + [Theory] + [InlineData("", 1)] + [InlineData(null, 1)] + [InlineData(DefaultDbUser.Email, 0)] + [InlineData(DefaultDbUser.Email, -1)] + internal void GenerateSessionTokenUserFailTest(string email, double days) + { + var user = DefaultDbUser.Instance with { Email = email }; + Assert.ThrowsAny(() => _service.GenerateSessionToken(user, DateTime.UtcNow.AddDays(days))); + } + + [Theory] + [InlineData(DefaultDbUser.Email, 7)] + [InlineData(DefaultDbUser.Email, 1)] + [InlineData(DefaultDbUser.Email, 0.5)] + [InlineData(DefaultDbUser.Email, 0.1)] + internal void GenerateSessionTokenTest(string email, double days) + { + var token = _service.GenerateSessionToken(email, UserType.Administrator, DateTime.UtcNow.AddDays(days)); + Assert.NotEmpty(token); + } +} \ No newline at end of file diff --git a/ArtwiseTests/UnitTest1.cs b/ArtwiseTests/UnitTest1.cs deleted file mode 100644 index 00e3d1f..0000000 --- a/ArtwiseTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ArtwiseTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/ArtwiseTests/ValidationTests/AuthenticationRequestValidationTest.cs b/ArtwiseTests/ValidationTests/AuthenticationRequestValidationTest.cs new file mode 100644 index 0000000..00b55ba --- /dev/null +++ b/ArtwiseTests/ValidationTests/AuthenticationRequestValidationTest.cs @@ -0,0 +1,27 @@ +using ArtwiseAPI.Features.Authentication.Models; +using System.ComponentModel.DataAnnotations; + +namespace ArtwiseTests.ValidationTests; + +public sealed class AuthenticationRequestValidationTest +{ + [Theory] + [InlineData(0, DefaultDbUser.Email, DefaultDbUser.Password)] + [InlineData(0, "avalid@email.com.xz", "a valid password")] + [InlineData(1, "", DefaultDbUser.Password)] + [InlineData(1, null, DefaultDbUser.Password)] + [InlineData(1, "invalid", DefaultDbUser.Password)] + [InlineData(1, "invalid@", DefaultDbUser.Password)] + [InlineData(1, "invalid@email", DefaultDbUser.Password)] + [InlineData(1, "invalid@email.", DefaultDbUser.Password)] + [InlineData(1, DefaultDbUser.Email, "")] + [InlineData(1, DefaultDbUser.Email, null)] + [InlineData(2, "invalid@email.", "")] + internal void AuthenticationRequestTests(int failsExpected, string email, string password) + { + var request = new AuthenticationRequest(email, password); + var context = new ValidationContext(request); + + Assert.Equal(failsExpected, request.Validate(context).Count()); + } +} \ No newline at end of file