diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index 83d1321b..52e46329 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -1,7 +1,7 @@ name: Build and test solution env: - DOTNET_VERSION: 6 + DOTNET_VERSION: 8 on: workflow_call: diff --git a/coffeecard/CoffeeCard.Common/CoffeeCard.Common.csproj b/coffeecard/CoffeeCard.Common/CoffeeCard.Common.csproj index b337e671..a5c9273e 100644 --- a/coffeecard/CoffeeCard.Common/CoffeeCard.Common.csproj +++ b/coffeecard/CoffeeCard.Common/CoffeeCard.Common.csproj @@ -1,11 +1,10 @@ - netstandard2.1 + net8.0 33C1BE09-BD3A-4584-B195-C57B47B9063C - - + \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Generators/Builder/BuilderGenerator.cs b/coffeecard/CoffeeCard.Generators/Builder/BuilderGenerator.cs new file mode 100644 index 00000000..cf916185 --- /dev/null +++ b/coffeecard/CoffeeCard.Generators/Builder/BuilderGenerator.cs @@ -0,0 +1,134 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CoffeeCard.Generators.Builder; + +[Generator] +public class BuilderGenerator : IIncrementalGenerator +{ + private const string BuilderForAttribute = "CoffeeCard.Tests.Common.BuilderForAttribute"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var namedTypeSymbols = context.SyntaxProvider + .ForAttributeWithMetadataName( + fullyQualifiedMetadataName: BuilderForAttribute, + predicate: IsSyntaxTargetForGeneration, + transform: GetSemanticTargetForGeneration) + .Where(t => t is not null).Collect(); + + context.RegisterSourceOutput(namedTypeSymbols, (productionContext, array) => + { + foreach (var typeSymbol in array) + { + //Retrieve the entity it is a builder for + var entity = (INamedTypeSymbol)typeSymbol.GetAttributes() + .Single(attr => attr.AttributeClass.Name == "BuilderForAttribute").ConstructorArguments[0].Value; + var code = GenerateBuilderCode(typeSymbol, entity); + var sourceText = SourceText.From(code, Encoding.UTF8); + productionContext.AddSource($"{typeSymbol.Name}.g.cs", sourceText); + } + }); + } + + private static bool IsSyntaxTargetForGeneration( + SyntaxNode syntaxNode, + CancellationToken cancellationToken) + { + return syntaxNode is ClassDeclarationSyntax classDeclaration; + } + + private static INamedTypeSymbol GetSemanticTargetForGeneration(GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken) + { + return (INamedTypeSymbol)context.TargetSymbol; + } + + private string GenerateBuilderCode(INamedTypeSymbol typeSymbol, ITypeSymbol entity) + { + var codeBuilder = new StringBuilder(); + + codeBuilder.AppendLine("// "); + codeBuilder.AppendLine("using System;"); + codeBuilder.AppendLine("using AutoBogus.Conventions;"); + codeBuilder.AppendLine($"using {entity.ContainingNamespace};"); + codeBuilder.AppendLine(); + codeBuilder.AppendLine($"namespace {typeSymbol.ContainingNamespace};"); + codeBuilder.AppendLine(); + + codeBuilder.AppendLine($"public partial class {typeSymbol.Name} : BaseBuilder<{entity.Name}>, IBuilder<{typeSymbol.Name}>"); + codeBuilder.AppendLine("{"); + + // Retrieve all properties of the given entity + var properties = entity.GetMembers().OfType().Where(p => p.Kind == SymbolKind.Property); + + var entityNameChar = entity.Name.ToLowerInvariant()[0]; + // Generate builder methods for all properties + var configBuilder = new StringBuilder(); + foreach (var property in properties) + { + if (property.Name.Contains("Id")) + { + configBuilder.AppendLine($" .WithSkip<{entity.Name}>(\"{property.Name}\")"); + } + AddWithPropertyValueToCodeBuilder(codeBuilder: codeBuilder, + typeSymbol: typeSymbol, + property: property, + entityNameChar: entityNameChar); + + AddWithPropertySetterToCodeBuilder( + codeBuilder: codeBuilder, + typeSymbol: typeSymbol, + property: property, + entityNameChar: entityNameChar); + } + AddPrivateConstructorToCodeBuilder(codeBuilder, typeSymbol, configBuilder); + + // End class + codeBuilder.AppendLine("}"); + + return codeBuilder.ToString(); + } + + /// + /// Generates a private constructor for the builder, to ensure the simple, or typical methods are used for instantiation + /// + /// + /// + /// + private void AddPrivateConstructorToCodeBuilder(StringBuilder codeBuilder, ITypeSymbol typeSymbol, StringBuilder configBuilder) + { + codeBuilder.AppendLine( + $" private {typeSymbol.Name} ()"); + codeBuilder.AppendLine(" {"); + codeBuilder.AppendLine(" Faker.Configure(builder => builder"); + codeBuilder.Append($"{configBuilder}"); + codeBuilder.AppendLine(" .WithConventions());"); + codeBuilder.AppendLine(" }"); + } + + private void AddWithPropertyValueToCodeBuilder(StringBuilder codeBuilder, ITypeSymbol typeSymbol, IPropertySymbol property, char entityNameChar) + { + codeBuilder.AppendLine( + $" public {typeSymbol.Name} With{property.Name}({property.Type} {property.Name}Value)"); + codeBuilder.AppendLine(" {"); + + codeBuilder.AppendLine( + $" Faker.RuleFor({entityNameChar} => {entityNameChar}.{property.Name}, {property.Name}Value);"); + codeBuilder.AppendLine(" return this;"); + codeBuilder.AppendLine(" }"); + } + private void AddWithPropertySetterToCodeBuilder(StringBuilder codeBuilder, ITypeSymbol typeSymbol, IPropertySymbol property, char entityNameChar) + { + codeBuilder.AppendLine( + $" public {typeSymbol.Name} With{property.Name}(Func {property.Name}Setter)"); + codeBuilder.AppendLine(" {"); + + codeBuilder.AppendLine( + $" Faker.RuleFor({entityNameChar} => {entityNameChar}.{property.Name}, {property.Name}Setter);"); + codeBuilder.AppendLine(" return this;"); + codeBuilder.AppendLine(" }"); + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Generators/CoffeeCard.Generators.csproj b/coffeecard/CoffeeCard.Generators/CoffeeCard.Generators.csproj new file mode 100644 index 00000000..70d54689 --- /dev/null +++ b/coffeecard/CoffeeCard.Generators/CoffeeCard.Generators.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + enable + enable + 11.0 + + + $(BaseIntermediateOutputPath)GeneratedCode\ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/coffeecard/CoffeeCard.Library/CoffeeCard.Library.csproj b/coffeecard/CoffeeCard.Library/CoffeeCard.Library.csproj index e9b1c175..859831b4 100644 --- a/coffeecard/CoffeeCard.Library/CoffeeCard.Library.csproj +++ b/coffeecard/CoffeeCard.Library/CoffeeCard.Library.csproj @@ -1,46 +1,39 @@ - - - net6.0 - 8 - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + net8.0 + default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/coffeecard/CoffeeCard.MobilePay.GenerateApi/CoffeeCard.MobilePay.GenerateApi.csproj b/coffeecard/CoffeeCard.MobilePay.GenerateApi/CoffeeCard.MobilePay.GenerateApi.csproj index a929ca87..a7558958 100644 --- a/coffeecard/CoffeeCard.MobilePay.GenerateApi/CoffeeCard.MobilePay.GenerateApi.csproj +++ b/coffeecard/CoffeeCard.MobilePay.GenerateApi/CoffeeCard.MobilePay.GenerateApi.csproj @@ -1,15 +1,12 @@ - - - Exe - net6.0 - enable - 8 - - - - - - - + + Exe + net7.0 + enable + default + + + + + diff --git a/coffeecard/CoffeeCard.MobilePay/CoffeeCard.MobilePay.csproj b/coffeecard/CoffeeCard.MobilePay/CoffeeCard.MobilePay.csproj index 0a9ab86a..6af6845e 100644 --- a/coffeecard/CoffeeCard.MobilePay/CoffeeCard.MobilePay.csproj +++ b/coffeecard/CoffeeCard.MobilePay/CoffeeCard.MobilePay.csproj @@ -1,18 +1,17 @@ - net6.0 + net8.0 AFF9D584-58F4-4A8D-A6BF-515890A9A3EF enable - - - - - + + + + diff --git a/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj b/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj index 33b2ba20..1e4e2bb0 100644 --- a/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj +++ b/coffeecard/CoffeeCard.Models/CoffeeCard.Models.csproj @@ -1,22 +1,18 @@ - - - net6.0 - true - enable - default - - - - - - - - - - - - - - + + net8.0 + true + enable + default + + + + + + + + + + + diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/FreeProductPaymentDetails.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/FreeProductPaymentDetails.cs index 16ce980d..159a15d0 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/FreeProductPaymentDetails.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/FreeProductPaymentDetails.cs @@ -1,6 +1,4 @@ using System.Runtime.Serialization; -using System.Text.Json.Serialization; -using NJsonSchema.Converters; namespace CoffeeCard.Models.DataTransferObjects.v2.Purchase { @@ -14,7 +12,6 @@ namespace CoffeeCard.Models.DataTransferObjects.v2.Purchase /// } /// [KnownType(typeof(FreePurchasePaymentDetails))] - [JsonConverter(typeof(JsonInheritanceConverter))] public class FreePurchasePaymentDetails : PaymentDetails { /// diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/MobilePayPaymentDetails.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/MobilePayPaymentDetails.cs index 61b8215d..3fd4795d 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/MobilePayPaymentDetails.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/MobilePayPaymentDetails.cs @@ -1,5 +1,4 @@ using Newtonsoft.Json; -using NJsonSchema.Converters; using System.ComponentModel.DataAnnotations; using System.Runtime.Serialization; @@ -17,7 +16,6 @@ namespace CoffeeCard.Models.DataTransferObjects.v2.Purchase /// } /// [KnownType(typeof(MobilePayPaymentDetails))] - [JsonConverter(typeof(JsonInheritanceConverter))] public class MobilePayPaymentDetails : PaymentDetails { /// diff --git a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/PaymentDetails.cs b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/PaymentDetails.cs index 378d5a63..1b72271e 100644 --- a/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/PaymentDetails.cs +++ b/coffeecard/CoffeeCard.Models/DataTransferObjects/v2/Purchase/PaymentDetails.cs @@ -1,13 +1,10 @@ using System.ComponentModel.DataAnnotations; using System.Runtime.Serialization; -using System.Text.Json.Serialization; -using NJsonSchema.Converters; namespace CoffeeCard.Models.DataTransferObjects.v2.Purchase { [KnownType(typeof(MobilePayPaymentDetails))] [KnownType(typeof(FreePurchasePaymentDetails))] - [JsonConverter(typeof(JsonInheritanceConverter))] public abstract class PaymentDetails { /// diff --git a/coffeecard/CoffeeCard.Tests.Common/BuilderForAttribute.cs b/coffeecard/CoffeeCard.Tests.Common/BuilderForAttribute.cs new file mode 100644 index 00000000..381492a7 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/BuilderForAttribute.cs @@ -0,0 +1,17 @@ +namespace CoffeeCard.Tests.Common; + +/// +/// Serves as a marker attribute used for source generation +/// The Type given, will have builder methods generated for it +/// The methods will be in the format WithPropertyName and allow for fluent api style configuration +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +sealed class BuilderForAttribute : Attribute +{ + public Type EntityType { get; } + + public BuilderForAttribute(Type entityType) + { + EntityType = entityType; + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/BaseBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/BaseBuilder.cs new file mode 100644 index 00000000..2762820a --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/BaseBuilder.cs @@ -0,0 +1,48 @@ +using AutoBogus; +using AutoBogus.Conventions; + +namespace CoffeeCard.Tests.Common.Builders +{ + public abstract class BaseBuilder where T : class + { + protected readonly AutoFaker Faker = new(); + protected BaseBuilder() + { + Faker.Configure(builder => builder.WithConventions()); + } + + /// + /// Creates a new instance of type T, + /// using the configuration set by using the builderMethods + /// + public T Build() + { + return Faker.Generate(); + } + + /// + /// Creates a new list of type T, + /// using the configuration set by using the builderMethods + /// + public List Build(int count) + { + return Faker.Generate(count); + } + } + + public interface IBuilder where T : class + { + /// + /// Gives a standard configured builder, + /// where lists of linked entities are empty + /// and nullable enities are null + /// + public static abstract T Simple(); + + /// + /// Gives a standard configured builder, + /// where all linked entities are populated + /// + public static abstract T Typical(); + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/ProductBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/ProductBuilder.cs new file mode 100644 index 00000000..50db3e93 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/ProductBuilder.cs @@ -0,0 +1,22 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(Product))] + public partial class ProductBuilder + { + public static ProductBuilder Simple() + { + return new ProductBuilder() + .WithName(f => f.Commerce.ProductName()) + .WithDescription(f => f.Commerce.ProductDescription()) + .WithNumberOfTickets(f => f.PickRandom(1, 10)) + .WithProductUserGroup(new List()); + } + + public static ProductBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/ProductUserGroupBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/ProductUserGroupBuilder.cs new file mode 100644 index 00000000..91011ab0 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/ProductUserGroupBuilder.cs @@ -0,0 +1,22 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(ProductUserGroup))] + public partial class ProductUserGroupBuilder + { + public static ProductUserGroupBuilder Simple() + { + var product = ProductBuilder.Simple().Build(); + return new ProductUserGroupBuilder() + .WithProduct(product) + .WithUserGroup(f => + f.PickRandom(UserGroup.Barista, UserGroup.Board, UserGroup.Customer, UserGroup.Manager)); + } + + public static ProductUserGroupBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/ProgrammeBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/ProgrammeBuilder.cs new file mode 100644 index 00000000..bda9291d --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/ProgrammeBuilder.cs @@ -0,0 +1,21 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(Programme))] + public partial class ProgrammeBuilder + { + public static ProgrammeBuilder Simple() + { + return new ProgrammeBuilder() + .WithUsers(new List()) + .WithShortName(f => f.Random.String2(3)) + .WithFullName(f => f.Commerce.Department()); + } + + public static ProgrammeBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/PurchaseBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/PurchaseBuilder.cs new file mode 100644 index 00000000..783fcdd5 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/PurchaseBuilder.cs @@ -0,0 +1,23 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(Purchase))] + public partial class PurchaseBuilder + { + public static PurchaseBuilder Simple() + { + var purchasedBy = UserBuilder.Simple().Build(); + return new PurchaseBuilder().WithPurchasedBy(purchasedBy) + .WithProductName(f => f.Commerce.ProductName()) + .WithTickets(new List()) + .WithTickets(new List()) + .WithNumberOfTickets(0); + } + + public static PurchaseBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/StatisticBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/StatisticBuilder.cs new file mode 100644 index 00000000..5fa02625 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/StatisticBuilder.cs @@ -0,0 +1,20 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(Statistic))] + public partial class StatisticBuilder + { + public static StatisticBuilder Simple() + { + var user = UserBuilder.Simple().Build(); + return new StatisticBuilder() + .WithUser(user); + } + + public static StatisticBuilder Typical() + { + return Simple(); + } + } +} diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/TicketBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/TicketBuilder.cs new file mode 100644 index 00000000..716835ca --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/TicketBuilder.cs @@ -0,0 +1,22 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(Ticket))] + public partial class TicketBuilder + { + public static TicketBuilder Simple() + { + var owner = UserBuilder.Simple().Build(); + var purchase = PurchaseBuilder.Simple().Build(); + return new TicketBuilder() + .WithOwner(owner) + .WithPurchase(purchase); + } + + public static TicketBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs new file mode 100644 index 00000000..fb6d090b --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/TokenBuilder.cs @@ -0,0 +1,19 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(Token))] + public partial class TokenBuilder + { + public static TokenBuilder Simple() + { + return new TokenBuilder() + .WithUser(UserBuilder.Simple().Build()); + } + + public static TokenBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/UserBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/UserBuilder.cs new file mode 100644 index 00000000..15b83e69 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/UserBuilder.cs @@ -0,0 +1,34 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(User))] + public partial class UserBuilder + { + public static UserBuilder Simple() + { + var programme = ProgrammeBuilder.Simple().Build(); + + return new UserBuilder() + .WithProgramme(programme) + .WithPurchases(new List()) + .WithStatistics(new List()) + .WithLoginAttempts(new List()) + .WithTokens(new List()) + .WithUserState(UserState.Active) + .WithTickets(new List()); + } + + public static UserBuilder DefaultCustomer() + { + return Simple() + .WithUserGroup(UserGroup.Customer) + .WithIsVerified(true); + } + + public static UserBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/Builders/VoucherBuilder.cs b/coffeecard/CoffeeCard.Tests.Common/Builders/VoucherBuilder.cs new file mode 100644 index 00000000..90530e6a --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/Builders/VoucherBuilder.cs @@ -0,0 +1,21 @@ +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Common.Builders +{ + [BuilderFor(typeof(Voucher))] + public partial class VoucherBuilder + { + public static VoucherBuilder Simple() + { + var product = ProductBuilder.Simple().Build(); + return new VoucherBuilder() + .WithProduct(product) + .WithUser(f => null); + } + + public static VoucherBuilder Typical() + { + return Simple(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Common/CoffeeCard.Tests.Common.csproj b/coffeecard/CoffeeCard.Tests.Common/CoffeeCard.Tests.Common.csproj new file mode 100644 index 00000000..69cf9e18 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Common/CoffeeCard.Tests.Common.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + net8.0 + enable + enable + + \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj b/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj index 723d78cf..243b45ef 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj +++ b/coffeecard/CoffeeCard.Tests.Integration/CoffeeCard.Tests.Integration.csproj @@ -1,8 +1,8 @@  - net6.0 + net8.0 false - 8 + default 81E81CE9-7DDA-4A9B-860B-6A473BC4F06F @@ -10,19 +10,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -30,9 +21,16 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + @@ -42,4 +40,4 @@ - \ No newline at end of file + diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/Account/GetAccountTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/Account/GetAccountTest.cs new file mode 100644 index 00000000..a35c64da --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/Account/GetAccountTest.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using CoffeeCard.Tests.Integration.WebApplication; +using CoffeeCard.WebApi; +using Xunit; +using CoffeeCard.Models.DataTransferObjects.v2.User; +using CoffeeCard.Tests.Common.Builders; +using CoffeeCard.Models.Entities; + +namespace CoffeeCard.Tests.Integration.Controllers.Account +{ + + public class GetAccountTest : BaseIntegrationTest + { + private const string GetAccountUrl = "api/v2/account"; + public GetAccountTest(CustomWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task Get_account_succeeds_when_authenticated_for_existing_account() + { + var user = UserBuilder.DefaultCustomer().Build(); + await Context.Users.AddAsync(user); + await Context.SaveChangesAsync(); + SetDefaultAuthHeader(user); + + var response = await Client.GetAsync(GetAccountUrl); + var account = await DeserializeResponseAsync(response); + + Assert.Equal(user.Email, account.Email); + Assert.Equal(user.Name, account.Name); + Assert.Equal(user.Programme.FullName, account.Programme.FullName); + Assert.Equal(user.UserGroup.toUserRole(), account.Role); + } + + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Integration/Controllers/Account/LoginTest.cs b/coffeecard/CoffeeCard.Tests.Integration/Controllers/Account/LoginTest.cs new file mode 100644 index 00000000..191aa661 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Integration/Controllers/Account/LoginTest.cs @@ -0,0 +1,69 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using CoffeeCard.Models.DataTransferObjects.User; +using CoffeeCard.Tests.Common.Builders; +using CoffeeCard.Tests.Integration.WebApplication; +using CoffeeCard.WebApi; +using Xunit; + +namespace CoffeeCard.Tests.Integration.Controllers.Account +{ + + public class LoginTest : BaseIntegrationTest + { + private const string LoginUrl = "api/v1/account/login"; + public LoginTest(CustomWebApplicationFactory factory) : base(factory) + { + } + + [Fact] + public async Task Unknown_user_login_fails() + { + var loginRequest = new LoginDto + { + Password = "test", + Email = "test@email.dk", + Version = "2.1.0" + }; + + var response = await Client.PostAsJsonAsync(LoginUrl, loginRequest); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Known_user_login_succeeds_returns_token() + { + var user = UserBuilder.DefaultCustomer().Build(); + var plaintextPassword = user.Password; + user.Password = HashPassword(plaintextPassword + user.Salt); + + await Context.Users.AddAsync(user); + await Context.SaveChangesAsync(); + + var loginRequest = new LoginDto + { + Password = plaintextPassword, + Email = user.Email, + Version = "2.1.0" + }; + var response = await Client.PostAsJsonAsync(LoginUrl, loginRequest); + + var token = await DeserializeResponseAsync(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(token.Token!); + } + + private static string HashPassword(string password) + { + var byteArr = Encoding.UTF8.GetBytes(password); + using var hasher = SHA256.Create(); + var hashBytes = hasher.ComputeHash(byteArr); + return Convert.ToBase64String(hashBytes); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs new file mode 100644 index 00000000..155d71f3 --- /dev/null +++ b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/BaseIntegrationTest.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using CoffeeCard.Library.Persistence; +using CoffeeCard.Models.Entities; +using CoffeeCard.WebApi; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using Xunit; + +namespace CoffeeCard.Tests.Integration.WebApplication +{ + [Collection("Integration tests, to be run sequentially")] + public abstract class BaseIntegrationTest : CustomWebApplicationFactory, IClassFixture> + { + private readonly CustomWebApplicationFactory _factory; + private readonly IServiceScope _scope; + protected readonly HttpClient Client; + protected readonly CoffeeCardContext Context; + + protected BaseIntegrationTest(CustomWebApplicationFactory factory) + { + // Set the random seed used for generation of data in the builders + // This ensures our tests are deterministic within a specific version of the code + var seed = new Random(42); + Bogus.Randomizer.Seed = seed; + _factory = factory; + _scope = _factory.Services.CreateScope(); + + Client = GetHttpClient(); + Context = GetCoffeeCardContext(); + } + + private HttpClient GetHttpClient() + { + var client = CreateClient(); + + return client; + } + + protected void SetDefaultAuthHeader(User user) + { + var claims = new[] + { + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Name), + new Claim("UserId", user.Id.ToString()), + new Claim(ClaimTypes.Role, user.UserGroup.ToString()) + }; + var token = GenerateToken(claims); + Client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("bearer", token); + } + + private string GenerateToken(IEnumerable claims) + { + var scopedServices = _scope.ServiceProvider; + var identitySettings = scopedServices.GetRequiredService(); + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(identitySettings.TokenKey)); // get token from appsettings.json + + var jwt = new JwtSecurityToken("AnalogIO", + "Everyone", + claims, + DateTime.UtcNow, + DateTime.UtcNow.AddHours(24), + new SigningCredentials(key, SecurityAlgorithms.HmacSha256) + ); + + return new JwtSecurityTokenHandler().WriteToken(jwt); //the method is called WriteToken but returns a string + } + + protected void RemoveRequestHeaders() + { + Client.DefaultRequestHeaders.Clear(); + } + + private CoffeeCardContext GetCoffeeCardContext() + { + // Create a scope to obtain a reference to the database context (ApplicationDbContext). + var scopedServices = _scope.ServiceProvider; + var context = scopedServices.GetRequiredService(); + + // Ensure the database is cleaned for each test run + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + + return context; + } + + /// + /// Helper method to deserialize a response from the api + /// + protected static async Task DeserializeResponseAsync(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(content); + } + + public override ValueTask DisposeAsync() + { + _scope.Dispose(); + return base.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/CustomWebApplicationFactory.cs b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/CustomWebApplicationFactory.cs index c7c272f1..8037cf50 100644 --- a/coffeecard/CoffeeCard.Tests.Integration/WebApplication/CustomWebApplicationFactory.cs +++ b/coffeecard/CoffeeCard.Tests.Integration/WebApplication/CustomWebApplicationFactory.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using CoffeeCard.Common.Configuration; using CoffeeCard.Library.Persistence; @@ -51,13 +52,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // Build the service provider var sp = services.BuildServiceProvider(); - // Create a scope to obtain a reference to the database context (ApplicationDbContext). - using var scope = sp.CreateScope(); - var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService(); - - // Ensure the database is created. - db.Database.EnsureCreated(); }); } } diff --git a/coffeecard/CoffeeCard.Tests.Unit/CoffeeCard.Tests.Unit.csproj b/coffeecard/CoffeeCard.Tests.Unit/CoffeeCard.Tests.Unit.csproj index 9e7c3edc..05587902 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/CoffeeCard.Tests.Unit.csproj +++ b/coffeecard/CoffeeCard.Tests.Unit/CoffeeCard.Tests.Unit.csproj @@ -1,8 +1,8 @@ - net6.0 + net8.0 false - 8 + default 24274C8F-678F-4E4D-98A0-2899163BCCBA @@ -10,7 +10,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - @@ -22,9 +21,10 @@ all runtime; build; native; contentfiles; analyzers + - \ No newline at end of file + diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs index 34d49b41..d3ca0612 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/AccountServiceTest.cs @@ -632,7 +632,7 @@ public async Task VerifyRegistrationReturnsTrueGivenValidToken() }; var identitySettings = new IdentitySettings { - TokenKey = "This is a long test token key" + TokenKey = "SuperLongSigningKeySuperLongSigningKey" }; var tokenService = new TokenService(identitySettings, new ClaimsUtilities(context)); @@ -697,7 +697,7 @@ private static string WriteTokenString(IEnumerable claims) { var tokenHandler = new JwtSecurityTokenHandler(); var key = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes("This is a long test token key")); + Encoding.UTF8.GetBytes("SuperLongSigningKeySuperLongSigningKey")); var jwt = new JwtSecurityToken("AnalogIO", "Everyone", diff --git a/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs b/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs index ef628af9..3c9ddb31 100644 --- a/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs +++ b/coffeecard/CoffeeCard.Tests.Unit/Services/TokenServiceTest.cs @@ -22,7 +22,7 @@ public TokenServiceTest() _identity = new IdentitySettings(); //creates the key for signing the token - const string keyForHmacSha256 = "signingKey"; + const string keyForHmacSha256 = "SuperLongSigningKey"; _identity.TokenKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(keyForHmacSha256)).ToString(); } @@ -99,7 +99,7 @@ public async Task ValidateTokenGivenInvalidSignedTokenReturnsFalse() await using (context) { var key = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes("Invalid signing key")); + Encoding.UTF8.GetBytes("Super long invalid signing key, longer than 256bytes")); var jwt = new JwtSecurityToken("AnalogIO", "Everyone", @@ -213,4 +213,4 @@ private CoffeeCardContext GenerateCoffeeCardContext(string uniqueString) return new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings); } } -} \ No newline at end of file +} diff --git a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj index 49798d10..5eebbc7e 100644 --- a/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj +++ b/coffeecard/CoffeeCard.WebApi/CoffeeCard.WebApi.csproj @@ -1,8 +1,8 @@  - net6.0 + net8.0 InProcess - 8 + default 5562C898-513F-493A-A335-417C39476714 d3bbf778-c5fe-4240-b2ca-dfa64082cc0e true @@ -30,29 +30,25 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - + - - + + + + + + + + + @@ -62,4 +58,4 @@ - \ No newline at end of file + diff --git a/coffeecard/CoffeeCard.WebApi/Dockerfile b/coffeecard/CoffeeCard.WebApi/Dockerfile index 63d1ca9e..945d0878 100644 --- a/coffeecard/CoffeeCard.WebApi/Dockerfile +++ b/coffeecard/CoffeeCard.WebApi/Dockerfile @@ -1,11 +1,11 @@ #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app EXPOSE 80 EXPOSE 443 -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["CoffeeCard.WebApi/CoffeeCard.WebApi.csproj", "CoffeeCard.WebApi/"] COPY ["CoffeeCard.Common/CoffeeCard.Common.csproj", "CoffeeCard.Common/"] diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index dc0aaa9e..ad1cffa8 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -241,7 +241,7 @@ private static void GenerateOpenApiDocument(IServiceCollection services) config.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("apikey")); // Assume not null as default unless parameter is marked as nullable - config.DefaultReferenceTypeNullHandling = ReferenceTypeNullHandling.NotNull; + // config.DefaultReferenceTypeNullHandling = ReferenceTypeNullHandling.NotNull; }); } } @@ -260,7 +260,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVers app.UseHsts(); app.UseOpenApi(); - app.UseSwaggerUi3(); + app.UseSwaggerUi(); app.UseHttpsRedirection(); @@ -289,4 +289,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApiVers } } #pragma warning restore CS1591 -} \ No newline at end of file +} diff --git a/coffeecard/CoffeeCard.WebApi/appsettings.json b/coffeecard/CoffeeCard.WebApi/appsettings.json index a5aaf876..5f78c643 100644 --- a/coffeecard/CoffeeCard.WebApi/appsettings.json +++ b/coffeecard/CoffeeCard.WebApi/appsettings.json @@ -13,7 +13,7 @@ "SchemaName": "dbo" }, "IdentitySettings": { - "TokenKey": "local-development-token", + "TokenKey": "super-long-local-development-token", "AdminToken": "local-development-admintoken", "ApiKey": "local-development-apikey" }, @@ -43,7 +43,7 @@ "Default": "Information", "Override": { "System": "Information", - "Microsoft": "Warning" + "Microsoft": "Information" } }, "WriteTo": [ diff --git a/coffeecard/CoffeeCardAPI.sln b/coffeecard/CoffeeCardAPI.sln index c3660e14..1ae693c7 100644 --- a/coffeecard/CoffeeCardAPI.sln +++ b/coffeecard/CoffeeCardAPI.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29806.167 @@ -13,11 +13,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeCard.MobilePay", "Cof EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeCard.Common", "CoffeeCard.Common\CoffeeCard.Common.csproj", "{2CCAF827-E341-48FC-ABCE-7EDFA3E84E65}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoffeeCard.Library", "CoffeeCard.Library\CoffeeCard.Library.csproj", "{F05C6E90-C02D-43BF-8BBB-615785C465DF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeCard.Library", "CoffeeCard.Library\CoffeeCard.Library.csproj", "{F05C6E90-C02D-43BF-8BBB-615785C465DF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoffeeCard.Models", "CoffeeCard.Models\CoffeeCard.Models.csproj", "{0BF48733-C3DF-4006-BD25-F5340710B5FA}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeCard.Models", "CoffeeCard.Models\CoffeeCard.Models.csproj", "{0BF48733-C3DF-4006-BD25-F5340710B5FA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoffeeCard.MobilePay.GenerateApi", "CoffeeCard.MobilePay.GenerateApi\CoffeeCard.MobilePay.GenerateApi.csproj", "{D295CCD7-DDB7-477D-82B6-53AFA97F5749}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeCard.MobilePay.GenerateApi", "CoffeeCard.MobilePay.GenerateApi\CoffeeCard.MobilePay.GenerateApi.csproj", "{D295CCD7-DDB7-477D-82B6-53AFA97F5749}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CoffeeCard.Common.Test", "CoffeeCard.Tests.Common\CoffeeCard.Tests.Common.csproj", "{938E0AD9-C1C8-4C10-B9FB-DD279DAD4D0B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoffeeCard.Generators", "CoffeeCard.Generators\CoffeeCard.Generators.csproj", "{BE332D7E-FD07-4878-B7E3-A06988F0C382}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -57,6 +61,14 @@ Global {D295CCD7-DDB7-477D-82B6-53AFA97F5749}.Debug|Any CPU.Build.0 = Debug|Any CPU {D295CCD7-DDB7-477D-82B6-53AFA97F5749}.Release|Any CPU.ActiveCfg = Release|Any CPU {D295CCD7-DDB7-477D-82B6-53AFA97F5749}.Release|Any CPU.Build.0 = Release|Any CPU + {938E0AD9-C1C8-4C10-B9FB-DD279DAD4D0B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {938E0AD9-C1C8-4C10-B9FB-DD279DAD4D0B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {938E0AD9-C1C8-4C10-B9FB-DD279DAD4D0B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {938E0AD9-C1C8-4C10-B9FB-DD279DAD4D0B}.Release|Any CPU.Build.0 = Release|Any CPU + {BE332D7E-FD07-4878-B7E3-A06988F0C382}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE332D7E-FD07-4878-B7E3-A06988F0C382}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE332D7E-FD07-4878-B7E3-A06988F0C382}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE332D7E-FD07-4878-B7E3-A06988F0C382}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/coffeecard/global.json b/coffeecard/global.json index 6fbe9d02..18b689d1 100644 --- a/coffeecard/global.json +++ b/coffeecard/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "6.0.0", - "rollForward": "latestMajor", - "allowPrerelease": true + "version": "8.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false } }