diff --git a/README.md b/README.md index b7629a5..1d51fbd 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,31 @@ # Introduction -To showcase my preferred patterns and practices, I have created a simple appointment scheduling api that allows -users to create, edit, and delete appointments to a calendar. Users can also create an account and a calendar. -An appointment is an object which is added to a calendar, and that calendar is associated with a user account. + +To showcase my preferred patterns and practices, I have created a simple appointment scheduling api that allows +users to create, edit, and delete appointments to a calendar. + +Users create an account and a calendar; Appointments are then added to the calendar. +Appointments can be updated and deleted. ## To Do + - REPR - Validation - Logging - Error Handling - CQRS -- Clean Architecture +- Clean Architecture ## Technologies + - Testcontainers - xUnit - EF Core for writing to the datastore - Dapper for reading from the datastore -- FluentValidation +- FluentValidation - Serilog ## Patterns + - Clean Architecture - CQRS - REPR (Request Endpoint Response) @@ -27,8 +33,10 @@ An appointment is an object which is added to a calendar, and that calendar is a - Feature Folders ## Prerequisites + - .NET 8 SDK -- The LATEST Docker for [Mac](https://docs.docker.com/desktop/install/mac-install/)/[Windows](https://docs.docker.com/desktop/install/windows-install/) +- The LATEST Docker + for [Mac](https://docs.docker.com/desktop/install/mac-install/)/[Windows](https://docs.docker.com/desktop/install/windows-install/) On Mac enable Rosetta in the beta features as shown in the image below: @@ -46,8 +54,9 @@ calendar. We will ignore authentication in this exercise. - Run `dotnet test` from the root of the project # Requirements + The requirements are written in the Given When Then format. This is a common format used -for acceptance criteria & tests. The requirements are written in a way that is not specific to any +for acceptance criteria & tests. The requirements are written in a way that is not specific to any particular technology. This allows us to write the tests first and then implement the functionality to make the tests pass. diff --git a/tests/Infrastructure.IntegrationTests/Authentication/TokenShould.cs b/tests/Infrastructure.IntegrationTests/Authentication/TokenShould.cs new file mode 100644 index 0000000..4bf12d6 --- /dev/null +++ b/tests/Infrastructure.IntegrationTests/Authentication/TokenShould.cs @@ -0,0 +1,157 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text.RegularExpressions; +using FluentAssertions; +using Microsoft.IdentityModel.Tokens; + +namespace Appointer.Infrastructure.IntegrationTests.Authentication; + +public class TokenShould +{ + private const string Issuer = "TestIssuer"; + private const string Audience = "TestAudience"; + private const string Secret = "super secret key that should be stored in a secure place and not in code"; + private const string JwtPattern = @"^[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+?\.[A-Za-z0-9-_]+$"; + + + [Fact] + public void BeCreated() + { + // arrange + var regex = new Regex(JwtPattern); + + // act + var token = CreateToken(); + + // assert + token.Should().NotBeEmpty(); + regex.IsMatch(token).Should().BeTrue(); + } + + [Fact] + public void BeValidated() + { + // arrange + var token = CreateToken(); + var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(Secret)); + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = Issuer, + ValidateAudience = true, + ValidAudience = Audience, + ValidateLifetime = true + }; + var handler = new JwtSecurityTokenHandler(); + + // act + var principal = handler.ValidateToken(token, validationParameters, out _); + + // assert + var claimsIdentity = new ClaimsIdentity(principal.Claims, "Bearer"); + var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); + claimsPrincipal.Identity?.IsAuthenticated.Should().BeTrue(); + claimsPrincipal.Identity?.AuthenticationType.Should().Be("Bearer"); + claimsPrincipal.Identity?.Name.Should().Be("Test User"); + claimsPrincipal.Claims.First(c => c.Type == ClaimTypes.Role).Value.Should().Be("Admin"); + claimsPrincipal.Claims.First(c => c.Type == ClaimTypes.Name).Value.Should().Be("Test User"); + } + + [Fact] + public void NotBeValidBySigningKey() + { + // arrange + var wrongSecret = "wrong secret key"; + var token = CreateToken(); + var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(wrongSecret)); + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = Issuer, + ValidateAudience = true, + ValidAudience = Audience, + }; + var handler = new JwtSecurityTokenHandler(); + + // act + Action act = () => handler.ValidateToken(token, validationParameters, out _); + + // assert + act.Should().Throw(); + } + + [Fact] + public void NotBeValidByAudience() + { + // arrange + var token = CreateToken(); + var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(Secret)); + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = Issuer, + ValidateAudience = true, + ValidAudience = "wrong audience", + }; + var handler = new JwtSecurityTokenHandler(); + + // act + Action act = () => handler.ValidateToken(token, validationParameters, out _); + + // assert + act.Should().Throw(); + } + + [Fact] + public void NotBeValidByIssuer() + { + // arrange + var token = CreateToken(); + var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(Secret)); + var validationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = key, + ValidateIssuer = true, + ValidIssuer = "wrong issuer", + ValidateAudience = true, + ValidAudience = Audience, + }; + var handler = new JwtSecurityTokenHandler(); + + // act + Action act = () => handler.ValidateToken(token, validationParameters, out _); + + // assert + act.Should().Throw(); + } + + private string CreateToken() + { + var claims = new List + { + new(ClaimTypes.Name, "Test User"), + new(ClaimTypes.Role, "Admin"), + }; + + // this symmetric key is used to both sign and verify the token + // a public and private key pair can be used instead for asymmetric encryption + var key = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(Secret)); + var cred = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature); + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + expires: DateTime.UtcNow.AddDays(1), + signingCredentials: cred + ); + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + return jwt; + } +} \ No newline at end of file