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