init
This commit is contained in:
81
.files/mccn-realm-export.json
Normal file
81
.files/mccn-realm-export.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"realm": "mccn",
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"failureFactor": 30,
|
||||
"accessTokenLifespan": 1800,
|
||||
"accessTokenLifespanForImplicitFlow": 900,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"offlineSessionIdleTimeout": 2592000,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "mccn-api",
|
||||
"name": "Mccn API",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "change-this-secret",
|
||||
"serviceAccountsEnabled": true,
|
||||
"authorizationServicesEnabled": false,
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false
|
||||
},
|
||||
{
|
||||
"clientId": "mccn-swagger",
|
||||
"name": "Mccn Swagger UI",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"redirectUris": [
|
||||
"http://localhost:5000/*",
|
||||
"http://localhost:5001/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:5000",
|
||||
"http://localhost:5001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "user",
|
||||
"description": "Regular user"
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Administrator"
|
||||
}
|
||||
]
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "service-account-mccn-api",
|
||||
"enabled": true,
|
||||
"serviceAccountClientId": "mccn-api",
|
||||
"clientRoleMappings": {
|
||||
"realm-management": [
|
||||
{ "name": "manage-users" },
|
||||
{ "name": "view-users" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
obj/
|
||||
bin/
|
||||
.vs/
|
||||
*.user
|
||||
.env
|
||||
.containers/
|
||||
.claude/
|
||||
src/API/Mccn.Api/appsettings.Development.json
|
||||
.idea/
|
||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
36
Mccn.slnx
Normal file
36
Mccn.slnx
Normal file
@@ -0,0 +1,36 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/"/>
|
||||
<Folder Name="/src/API/">
|
||||
<Project Path="src/API/Mccn.Api/Mccn.Api.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/src/Common/">
|
||||
<Project Path="src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj"/>
|
||||
<Project Path="src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj"/>
|
||||
<Project Path="src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj"/>
|
||||
<Project Path="src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/src/Modules/"/>
|
||||
<Folder Name="/src/Modules/Hello/">
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj"/>
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj"/>
|
||||
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/src/Modules/Users/">
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj"/>
|
||||
<Project
|
||||
Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj"/>
|
||||
<Project Path="src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj"/>
|
||||
</Folder>
|
||||
<Folder Name="/test/">
|
||||
<Project Path="test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj"/>
|
||||
</Folder>
|
||||
</Solution>
|
||||
51
docker-compose.yml
Normal file
51
docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
mccn.database:
|
||||
image: postgres:17
|
||||
container_name: mccn.database
|
||||
environment:
|
||||
POSTGRES_DB: mccn
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- ./.containers/db:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
mccn.identity:
|
||||
image: quay.io/keycloak/keycloak:26.5.1
|
||||
container_name: mccn.identity
|
||||
command: start-dev --import-realm
|
||||
environment:
|
||||
KC_HEALTH_ENABLED: true
|
||||
KC_HOSTNAME: localhost
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
volumes:
|
||||
- ./.files:/opt/keycloak/data/import
|
||||
ports:
|
||||
- 18080:8080
|
||||
- 9000:9000
|
||||
|
||||
mccn.seq:
|
||||
image: datalust/seq:2025.2
|
||||
container_name: mccn.seq
|
||||
environment:
|
||||
ACCEPT_EULA: Y
|
||||
SEQ_FIRSTRUN_NOAUTHENTICATION: true # local dev only
|
||||
ports:
|
||||
- 5341:5341
|
||||
- 9081:80
|
||||
|
||||
mccn.redis:
|
||||
image: redis:latest
|
||||
container_name: mccn.redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
mccn.jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
container_name: mccn.jaeger
|
||||
ports:
|
||||
- 4317:4317
|
||||
- 4318:4318
|
||||
- 16686:16686
|
||||
26
src/API/Mccn.Api/Extensions/MigrationExtensions.cs
Normal file
26
src/API/Mccn.Api/Extensions/MigrationExtensions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Mccn.Modules.Users.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Mccn.Api.Extensions;
|
||||
|
||||
internal static class MigrationExtensions
|
||||
{
|
||||
internal static async Task ApplyMigrationsAsync(this WebApplication app)
|
||||
{
|
||||
using IServiceScope scope = app.Services.CreateScope();
|
||||
await MigrateAsync<UsersDbContext>(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext
|
||||
{
|
||||
TContext dbContext = serviceProvider.GetRequiredService<TContext>();
|
||||
|
||||
// Use EnsureCreatedAsync when no migrations exist yet.
|
||||
// Once you add migrations via `dotnet ef migrations add`, switch this to MigrateAsync().
|
||||
IEnumerable<string> pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync();
|
||||
if (pendingMigrations.Any())
|
||||
await dbContext.Database.MigrateAsync();
|
||||
else
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
}
|
||||
}
|
||||
27
src/API/Mccn.Api/Mccn.Api.csproj
Normal file
27
src/API/Mccn.Api/Mccn.Api.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Common\Mccn.Common.Presentation\Mccn.Common.Presentation.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\Users\Mccn.Modules.Users.Infrastructure\Mccn.Modules.Users.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\Users\Mccn.Modules.Users.Presentation\Mccn.Modules.Users.Presentation.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\Hello\Mccn.Modules.Hello.Infrastructure\Mccn.Modules.Hello.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Modules\Hello\Mccn.Modules.Hello.Presentation\Mccn.Modules.Hello.Presentation.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
70
src/API/Mccn.Api/Mccn.Api.http
Normal file
70
src/API/Mccn.Api/Mccn.Api.http
Normal file
@@ -0,0 +1,70 @@
|
||||
### Variables
|
||||
@keycloak = http://localhost:18080
|
||||
@realm = mccn
|
||||
@client_id = mccn-swagger
|
||||
@content_type = application/json
|
||||
|
||||
|
||||
###############################################################################
|
||||
# HEALTH
|
||||
###############################################################################
|
||||
|
||||
### Health check
|
||||
GET {{baseAddress}}/health
|
||||
|
||||
|
||||
###############################################################################
|
||||
# AUTH — get a token from Keycloak
|
||||
# Uses the mccn-swagger public client with direct access grants (Resource Owner
|
||||
# Password Credentials). Replace the username/password with a registered user.
|
||||
###############################################################################
|
||||
|
||||
### Get access token
|
||||
# @name login
|
||||
POST {{keycloak}}/realms/{{realm}}/protocol/openid-connect/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type = password &
|
||||
client_id = {{client_id}} &
|
||||
username = user@example.com &
|
||||
password = Password1!
|
||||
|
||||
### Store the token for use in subsequent requests
|
||||
@token = {{login.response.body.access_token}}
|
||||
|
||||
|
||||
###############################################################################
|
||||
# USERS
|
||||
###############################################################################
|
||||
|
||||
### Register a new user (public)
|
||||
# @name register
|
||||
POST {{baseAddress}}/users/register
|
||||
Content-Type: {{content_type}}
|
||||
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"firstName": "Jane",
|
||||
"lastName": "Doe",
|
||||
"password": "Test1234!@#$anin149141"
|
||||
}
|
||||
|
||||
### Get user profile (requires auth)
|
||||
# Replace the GUID below with the ID returned from the register response
|
||||
GET {{baseAddress}}/users/{{register.response.body.value}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###############################################################################
|
||||
# HELLO
|
||||
###############################################################################
|
||||
|
||||
### Say hello — no name (public)
|
||||
GET {{baseAddress}}/hello
|
||||
|
||||
### Say hello — with a name (public)
|
||||
GET {{baseAddress}}/hello?name=World
|
||||
|
||||
### Say hello to the authenticated user (requires auth)
|
||||
GET {{baseAddress}}/hello/me
|
||||
Authorization: Bearer {{token}}
|
||||
61
src/API/Mccn.Api/Program.cs
Normal file
61
src/API/Mccn.Api/Program.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Mccn.Api.Extensions;
|
||||
using Mccn.Common.Application;
|
||||
using Mccn.Common.Infrastructure;
|
||||
using Mccn.Common.Presentation.Endpoints;
|
||||
using Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||
using Mccn.Modules.Hello.Infrastructure;
|
||||
using Mccn.Modules.Hello.Presentation;
|
||||
using Mccn.Modules.Users.Application.Users.Register;
|
||||
using Mccn.Modules.Users.Infrastructure;
|
||||
using Mccn.Modules.Users.Presentation;
|
||||
using Serilog;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((context, config) => { config.ReadFrom.Configuration(context.Configuration); });
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
builder.Services.AddApplication(
|
||||
typeof(RegisterUserCommand).Assembly,
|
||||
typeof(SayHelloQuery).Assembly);
|
||||
|
||||
// Register modules.
|
||||
builder.Services.AddUsersModule(builder.Configuration);
|
||||
builder.Services.AddHelloModule(builder.Configuration);
|
||||
|
||||
// Register endpoints.
|
||||
builder.Services.AddUsersPresentationServices();
|
||||
builder.Services.AddHelloPresentationServices();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Auto-apply migrations in development.
|
||||
await app.ApplyMigrationsAsync();
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapEndpoints();
|
||||
|
||||
app.MapGet("health", () => Results.Ok(new { Status = "Healthy" }))
|
||||
.WithTags("Health")
|
||||
.AllowAnonymous();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program accessible to integration test projects.
|
||||
public partial class Program;
|
||||
23
src/API/Mccn.Api/Properties/launchSettings.json
Normal file
23
src/API/Mccn.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:8080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:8081;http://localhost:8080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/API/Mccn.Api/appsettings.json
Normal file
55
src/API/Mccn.Api/appsettings.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.Seq"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Information",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console"
|
||||
},
|
||||
{
|
||||
"Name": "Seq",
|
||||
"Args": {
|
||||
"serverUrl": "http://localhost:9081"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId"
|
||||
]
|
||||
},
|
||||
"Authentication": {
|
||||
"Audience": "account",
|
||||
"ValidIssuers": [
|
||||
"http://mccn.identity:8080/realms/mccn",
|
||||
"http://localhost:18080/realms/mccn"
|
||||
],
|
||||
"MetadataAddress": "http://localhost:18080/realms/mccn/.well-known/openid-configuration",
|
||||
"RequireHttpsMetadata": false
|
||||
},
|
||||
"Observability": {
|
||||
"OtlpEndpoint": "http://localhost:4317"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Database": "Host=localhost;Port=5432;Database=mccn;Username=postgres;Password=postgres",
|
||||
"Cache": "localhost:6379"
|
||||
},
|
||||
"Users": {
|
||||
"Keycloak": {
|
||||
"AdminUrl": "http://localhost:18080/admin/realms/mccn/",
|
||||
"TokenUrl": "http://localhost:18080/realms/mccn/protocol/openid-connect/token",
|
||||
"ClientId": "mccn-api",
|
||||
"ClientSecret": "change-this-secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/API/Mccn.Api/http-client.env.json
Normal file
5
src/API/Mccn.Api/http-client.env.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dev": {
|
||||
"baseAddress": "https://localhost:8081"
|
||||
}
|
||||
}
|
||||
12
src/Common/Mccn.Common.Application/Abstractions/ICommand.cs
Normal file
12
src/Common/Mccn.Common.Application/Abstractions/ICommand.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace Mccn.Common.Application.Abstractions;
|
||||
|
||||
public interface ICommand : IRequest<Result>
|
||||
{
|
||||
}
|
||||
|
||||
public interface ICommand<TResponse> : IRequest<Result<TResponse>>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace Mccn.Common.Application.Abstractions;
|
||||
|
||||
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
|
||||
where TCommand : ICommand
|
||||
{
|
||||
}
|
||||
|
||||
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
|
||||
where TCommand : ICommand<TResponse>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace Mccn.Common.Application.Abstractions;
|
||||
|
||||
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using MediatR;
|
||||
|
||||
namespace Mccn.Common.Application.Abstractions;
|
||||
|
||||
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
|
||||
where TQuery : IQuery<TResponse>
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Mccn.Common.Application.Exceptions;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using MediatR;
|
||||
using ValidationException = Mccn.Common.Application.Exceptions.ValidationException;
|
||||
|
||||
namespace Mccn.Common.Application.Behaviors;
|
||||
|
||||
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IBaseRequest
|
||||
where TResponse : Result
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidationContext<TRequest> context = new(request);
|
||||
|
||||
ValidationResult[] validationFailures = await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
||||
|
||||
List<ValidationError> errors = validationFailures
|
||||
.Where(r => !r.IsValid)
|
||||
.SelectMany(r => r.Errors)
|
||||
.Select(f => new ValidationError(f.PropertyName, f.ErrorMessage))
|
||||
.ToList();
|
||||
|
||||
if (errors.Count != 0) throw new ValidationException(errors);
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
23
src/Common/Mccn.Common.Application/DependencyInjection.cs
Normal file
23
src/Common/Mccn.Common.Application/DependencyInjection.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Mccn.Common.Application.Behaviors;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Common.Application;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(
|
||||
this IServiceCollection services,
|
||||
params Assembly[] moduleAssemblies)
|
||||
{
|
||||
services.AddMediatR(config => { config.RegisterServicesFromAssemblies(moduleAssemblies); });
|
||||
|
||||
services.AddValidatorsFromAssemblies(moduleAssemblies);
|
||||
|
||||
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Mccn.Common.Application.Exceptions;
|
||||
|
||||
public sealed record ValidationError(string PropertyName, string ErrorMessage);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Mccn.Common.Application.Exceptions;
|
||||
|
||||
public sealed class ValidationException : Exception
|
||||
{
|
||||
public ValidationException(IEnumerable<ValidationError> errors)
|
||||
: base("One or more validation failures has occurred.")
|
||||
{
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationError> Errors { get; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2"/>
|
||||
<PackageReference Include="MediatR" Version="12.4.1"/>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
15
src/Common/Mccn.Common.Domain/Abstractions/Entity.cs
Normal file
15
src/Common/Mccn.Common.Domain/Abstractions/Entity.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace Mccn.Common.Domain.Abstractions;
|
||||
|
||||
public abstract class Entity
|
||||
{
|
||||
protected Entity(Guid id)
|
||||
{
|
||||
Id = id;
|
||||
}
|
||||
|
||||
protected Entity()
|
||||
{
|
||||
}
|
||||
|
||||
public Guid Id { get; init; }
|
||||
}
|
||||
35
src/Common/Mccn.Common.Domain/Abstractions/Error.cs
Normal file
35
src/Common/Mccn.Common.Domain/Abstractions/Error.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Mccn.Common.Domain.Abstractions;
|
||||
|
||||
public sealed record Error(string Code, string Description, ErrorType Type)
|
||||
{
|
||||
public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure);
|
||||
public static readonly Error NullValue = new("General.Null", "Null value was provided", ErrorType.Failure);
|
||||
|
||||
public static Error NotFound(string code, string description)
|
||||
{
|
||||
return new Error(code, description, ErrorType.NotFound);
|
||||
}
|
||||
|
||||
public static Error Validation(string code, string description)
|
||||
{
|
||||
return new Error(code, description, ErrorType.Validation);
|
||||
}
|
||||
|
||||
public static Error Conflict(string code, string description)
|
||||
{
|
||||
return new Error(code, description, ErrorType.Conflict);
|
||||
}
|
||||
|
||||
public static Error Failure(string code, string description)
|
||||
{
|
||||
return new Error(code, description, ErrorType.Failure);
|
||||
}
|
||||
}
|
||||
|
||||
public enum ErrorType
|
||||
{
|
||||
Failure = 0,
|
||||
Validation = 1,
|
||||
NotFound = 2,
|
||||
Conflict = 3
|
||||
}
|
||||
58
src/Common/Mccn.Common.Domain/Abstractions/Result.cs
Normal file
58
src/Common/Mccn.Common.Domain/Abstractions/Result.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace Mccn.Common.Domain.Abstractions;
|
||||
|
||||
public class Result
|
||||
{
|
||||
protected Result(bool isSuccess, Error error)
|
||||
{
|
||||
if (isSuccess && error != Error.None)
|
||||
throw new InvalidOperationException();
|
||||
if (!isSuccess && error == Error.None)
|
||||
throw new InvalidOperationException();
|
||||
|
||||
IsSuccess = isSuccess;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool IsSuccess { get; }
|
||||
public bool IsFailure => !IsSuccess;
|
||||
public Error Error { get; }
|
||||
|
||||
public static Result Success()
|
||||
{
|
||||
return new Result(true, Error.None);
|
||||
}
|
||||
|
||||
public static Result Failure(Error error)
|
||||
{
|
||||
return new Result(false, error);
|
||||
}
|
||||
|
||||
public static Result<TValue> Success<TValue>(TValue value)
|
||||
{
|
||||
return new Result<TValue>(value, true, Error.None);
|
||||
}
|
||||
|
||||
public static Result<TValue> Failure<TValue>(Error error)
|
||||
{
|
||||
return new Result<TValue>(default, false, error);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Result<TValue> : Result
|
||||
{
|
||||
private readonly TValue? _value;
|
||||
|
||||
internal Result(TValue? value, bool isSuccess, Error error) : base(isSuccess, error)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public TValue Value => IsSuccess
|
||||
? _value!
|
||||
: throw new InvalidOperationException("The value of a failure result cannot be accessed.");
|
||||
|
||||
public static implicit operator Result<TValue>(TValue? value)
|
||||
{
|
||||
return value is not null ? Success(value) : Failure<TValue>(Error.NullValue);
|
||||
}
|
||||
}
|
||||
9
src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj
Normal file
9
src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Common.Infrastructure.Authentication;
|
||||
|
||||
public static class AuthenticationExtensions
|
||||
{
|
||||
public static IServiceCollection AddJwtAuthentication(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services
|
||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer();
|
||||
|
||||
services.ConfigureOptions<JwtBearerConfigureOptions>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Mccn.Common.Infrastructure.Authentication;
|
||||
|
||||
internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions<JwtBearerOptions>
|
||||
{
|
||||
private const string ConfigSection = "Authentication";
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public JwtBearerConfigureOptions(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public void Configure(JwtBearerOptions options)
|
||||
{
|
||||
_configuration.GetSection(ConfigSection).Bind(options);
|
||||
}
|
||||
|
||||
public void Configure(string? name, JwtBearerOptions options)
|
||||
{
|
||||
Configure(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Common.Infrastructure.Caching;
|
||||
|
||||
public static class CachingExtensions
|
||||
{
|
||||
public static IServiceCollection AddCaching(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
string? redisConnectionString = configuration.GetConnectionString("Cache");
|
||||
|
||||
if (!string.IsNullOrEmpty(redisConnectionString))
|
||||
services.AddStackExchangeRedisCache(options => { options.Configuration = redisConnectionString; });
|
||||
else
|
||||
services.AddDistributedMemoryCache();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
31
src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs
Normal file
31
src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Mccn.Common.Infrastructure.Authentication;
|
||||
using Mccn.Common.Infrastructure.Caching;
|
||||
using Mccn.Common.Infrastructure.ExceptionHandlers;
|
||||
using Mccn.Common.Infrastructure.Observability;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Common.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddJwtAuthentication(configuration);
|
||||
|
||||
services.AddAuthorization();
|
||||
|
||||
services.AddCaching(configuration);
|
||||
|
||||
services.AddObservability("Mccn", configuration);
|
||||
|
||||
services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
services.AddProblemDetails();
|
||||
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Mccn.Common.Application.Exceptions;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Mccn.Common.Infrastructure.ExceptionHandlers;
|
||||
|
||||
internal sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
||||
|
||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger, IWebHostEnvironment env)
|
||||
{
|
||||
_logger = logger;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
public async ValueTask<bool> TryHandleAsync(
|
||||
HttpContext httpContext,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogError(exception, "Unhandled exception occurred");
|
||||
|
||||
if (exception is ValidationException validationException)
|
||||
{
|
||||
ValidationProblemDetails validationProblem = new()
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Validation Error",
|
||||
Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1"
|
||||
};
|
||||
|
||||
foreach (ValidationError error in validationException.Errors)
|
||||
validationProblem.Errors[error.PropertyName] = [error.ErrorMessage];
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await httpContext.Response.WriteAsJsonAsync(validationProblem, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
ProblemDetails problem = new()
|
||||
{
|
||||
Status = StatusCodes.Status500InternalServerError,
|
||||
Title = "Internal Server Error",
|
||||
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1"
|
||||
};
|
||||
|
||||
// Include exception details in development to make debugging easier.
|
||||
if (_env.IsDevelopment())
|
||||
{
|
||||
problem.Detail = exception.Message;
|
||||
problem.Extensions["exceptionType"] = exception.GetType().Name;
|
||||
problem.Extensions["stackTrace"] = exception.StackTrace;
|
||||
}
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
|
||||
<ProjectReference Include="..\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Mccn.Common.Infrastructure.Observability;
|
||||
|
||||
public static class ObservabilityExtensions
|
||||
{
|
||||
public static IServiceCollection AddObservability(
|
||||
this IServiceCollection services,
|
||||
string serviceName,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(serviceName))
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(
|
||||
configuration["Observability:OtlpEndpoint"] ?? "http://localhost:4317");
|
||||
});
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Common.Presentation.Endpoints;
|
||||
|
||||
public static class EndpointExtensions
|
||||
{
|
||||
public static IServiceCollection AddEndpoints(
|
||||
this IServiceCollection services,
|
||||
params Assembly[] assemblies)
|
||||
{
|
||||
IEnumerable<Type> endpointTypes = assemblies
|
||||
.SelectMany(a => a.GetTypes())
|
||||
.Where(t => t is { IsAbstract: false, IsInterface: false } &&
|
||||
t.IsAssignableTo(typeof(IEndpoint)));
|
||||
|
||||
foreach (Type endpointType in endpointTypes) services.AddTransient(typeof(IEndpoint), endpointType);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder MapEndpoints(
|
||||
this WebApplication app,
|
||||
RouteGroupBuilder? routeGroupBuilder = null)
|
||||
{
|
||||
IEnumerable<IEndpoint> endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();
|
||||
|
||||
IEndpointRouteBuilder builder = routeGroupBuilder is null ? app : routeGroupBuilder;
|
||||
|
||||
foreach (IEndpoint endpoint in endpoints) endpoint.MapEndpoint(builder);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Mccn.Common.Presentation.Endpoints;
|
||||
|
||||
public interface IEndpoint
|
||||
{
|
||||
void MapEndpoint(IEndpointRouteBuilder app);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Mccn.Modules.Hello.Application;
|
||||
|
||||
public static class AssemblyReference
|
||||
{
|
||||
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Modules.Hello.Application;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddHelloApplication(this IServiceCollection services)
|
||||
{
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||
|
||||
public sealed record HelloResponse(string Message);
|
||||
@@ -0,0 +1,5 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||
|
||||
public sealed record SayHelloQuery(string? Name) : IQuery<HelloResponse>;
|
||||
@@ -0,0 +1,16 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||
|
||||
internal sealed class SayHelloQueryHandler : IQueryHandler<SayHelloQuery, HelloResponse>
|
||||
{
|
||||
public Task<Result<HelloResponse>> Handle(SayHelloQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
string greeting = string.IsNullOrWhiteSpace(request.Name)
|
||||
? "Hello, World!"
|
||||
: $"Hello, {request.Name}!";
|
||||
|
||||
return Task.FromResult(Result.Success(new HelloResponse(greeting)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Reflection;
|
||||
using Mccn.Modules.Hello.Application;
|
||||
using DependencyInjection = Mccn.Modules.Hello.Infrastructure.DependencyInjection;
|
||||
|
||||
namespace Mccn.Modules.Hello.ArchitectureTests.Abstractions;
|
||||
|
||||
public abstract class BaseTest
|
||||
{
|
||||
protected static readonly Assembly ApplicationAssembly =
|
||||
AssemblyReference.Assembly;
|
||||
|
||||
protected static readonly Assembly InfrastructureAssembly =
|
||||
typeof(DependencyInjection).Assembly;
|
||||
|
||||
protected static readonly Assembly PresentationAssembly =
|
||||
Presentation.AssemblyReference.Assembly;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentAssertions;
|
||||
using NetArchTest.Rules;
|
||||
|
||||
namespace Mccn.Modules.Hello.ArchitectureTests.Abstractions;
|
||||
|
||||
internal static class TestResultExtensions
|
||||
{
|
||||
internal static void ShouldBeSuccessful(this TestResult testResult)
|
||||
{
|
||||
testResult.FailingTypes?.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
using Mccn.Modules.Hello.ArchitectureTests.Abstractions;
|
||||
using NetArchTest.Rules;
|
||||
|
||||
namespace Mccn.Modules.Hello.ArchitectureTests.Application;
|
||||
|
||||
public class ApplicationTests : BaseTest
|
||||
{
|
||||
[Fact]
|
||||
public void Query_Should_BeSealed()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQuery<>))
|
||||
.Should()
|
||||
.BeSealed()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ShouldHave_NameEndingWith_Query()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQuery<>))
|
||||
.Should()
|
||||
.HaveNameEndingWith("Query")
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryHandler_Should_NotBePublic()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQueryHandler<,>))
|
||||
.Should()
|
||||
.NotBePublic()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryHandler_Should_BeSealed()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQueryHandler<,>))
|
||||
.Should()
|
||||
.BeSealed()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryHandler_ShouldHave_NameEndingWith_QueryHandler()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQueryHandler<,>))
|
||||
.Should()
|
||||
.HaveNameEndingWith("QueryHandler")
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Mccn.Modules.Hello.ArchitectureTests.Abstractions;
|
||||
using NetArchTest.Rules;
|
||||
|
||||
namespace Mccn.Modules.Hello.ArchitectureTests.Layers;
|
||||
|
||||
public class LayerTests : BaseTest
|
||||
{
|
||||
[Fact]
|
||||
public void ApplicationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplicationLayer_ShouldNotHaveDependencyOn_PresentationLayer()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(PresentationAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresentationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
|
||||
{
|
||||
Types.InAssembly(PresentationAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="NetArchTest.Rules" Version="1.3.2"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Modules.Hello.Infrastructure\Mccn.Modules.Hello.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\Mccn.Modules.Hello.Presentation\Mccn.Modules.Hello.Presentation.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Modules.Hello.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddHelloModule(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// Hello module has no database/infrastructure dependencies.
|
||||
// This is where you'd add them when the module grows.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Modules.Hello.Application\Mccn.Modules.Hello.Application.csproj"/>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using Bogus;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Modules.Hello.IntegrationTests.Abstractions;
|
||||
|
||||
[Collection(nameof(IntegrationTestCollection))]
|
||||
public abstract class BaseIntegrationTest : IDisposable
|
||||
{
|
||||
protected static readonly Faker Faker = new();
|
||||
|
||||
private readonly IServiceScope _scope;
|
||||
protected readonly HttpClient HttpClient;
|
||||
protected readonly ISender Sender;
|
||||
|
||||
protected BaseIntegrationTest(IntegrationTestWebAppFactory factory)
|
||||
{
|
||||
_scope = factory.Services.CreateScope();
|
||||
HttpClient = factory.CreateClient();
|
||||
Sender = _scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scope.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Mccn.Modules.Hello.IntegrationTests.Abstractions;
|
||||
|
||||
[CollectionDefinition(nameof(IntegrationTestCollection))]
|
||||
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>;
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Testcontainers.Redis;
|
||||
|
||||
namespace Mccn.Modules.Hello.IntegrationTests.Abstractions;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17")
|
||||
.WithDatabase("mccn")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
.Build();
|
||||
|
||||
private readonly RedisContainer _redisContainer = new RedisBuilder()
|
||||
.WithImage("redis:latest")
|
||||
.Build();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString());
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings:Cache", _redisContainer.GetConnectionString());
|
||||
Environment.SetEnvironmentVariable("Authentication:RequireHttpsMetadata", "false");
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _dbContainer.StartAsync();
|
||||
await _redisContainer.StartAsync();
|
||||
}
|
||||
|
||||
public new async Task DisposeAsync()
|
||||
{
|
||||
await _dbContainer.StopAsync();
|
||||
await _redisContainer.StopAsync();
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||
using Mccn.Modules.Hello.IntegrationTests.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Hello.IntegrationTests.Hello;
|
||||
|
||||
public class HelloTests : BaseIntegrationTest
|
||||
{
|
||||
public HelloTests(IntegrationTestWebAppFactory factory)
|
||||
: base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHello_Should_ReturnOk_WithDefaultMessage()
|
||||
{
|
||||
// Act
|
||||
HttpResponseMessage response = await HttpClient.GetAsync("hello");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
HelloResponse? body = await response.Content.ReadFromJsonAsync<HelloResponse>();
|
||||
body!.Message.Should().Be("Hello, World!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHello_Should_ReturnOk_WithNamedMessage()
|
||||
{
|
||||
// Arrange
|
||||
string name = Faker.Name.FirstName();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await HttpClient.GetAsync($"hello?name={name}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
HelloResponse? body = await response.Content.ReadFromJsonAsync<HelloResponse>();
|
||||
body!.Message.Should().Be($"Hello, {name}!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SayHello_Should_ReturnHelloWorld_WhenNoName()
|
||||
{
|
||||
// Act
|
||||
Result<HelloResponse> result = await Sender.Send(new SayHelloQuery(null));
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Message.Should().Be("Hello, World!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SayHello_Should_ReturnGreeting_WhenNameProvided()
|
||||
{
|
||||
// Arrange
|
||||
string name = Faker.Name.FirstName();
|
||||
|
||||
// Act
|
||||
Result<HelloResponse> result = await Sender.Send(new SayHelloQuery(name));
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Message.Should().Be($"Hello, {name}!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHelloMe_Should_ReturnUnauthorized_WhenNotAuthenticated()
|
||||
{
|
||||
// Act
|
||||
HttpResponseMessage response = await HttpClient.GetAsync("hello/me");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bogus" Version="35.6.5"/>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="Testcontainers.Keycloak" Version="4.11.0"/>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0"/>
|
||||
<PackageReference Include="Testcontainers.Redis" Version="4.11.0"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\API\Mccn.Api\Mccn.Api.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="mccn-realm-export.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"realm": "mccn",
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"failureFactor": 30,
|
||||
"accessTokenLifespan": 1800,
|
||||
"accessTokenLifespanForImplicitFlow": 900,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"offlineSessionIdleTimeout": 2592000,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "mccn-api",
|
||||
"name": "Mccn API",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "change-this-secret",
|
||||
"serviceAccountsEnabled": true,
|
||||
"authorizationServicesEnabled": false,
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false
|
||||
},
|
||||
{
|
||||
"clientId": "mccn-swagger",
|
||||
"name": "Mccn Swagger UI",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"redirectUris": [
|
||||
"http://localhost:5000/*",
|
||||
"http://localhost:5001/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:5000",
|
||||
"http://localhost:5001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "user",
|
||||
"description": "Regular user"
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Administrator"
|
||||
}
|
||||
]
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "service-account-mccn-api",
|
||||
"enabled": true,
|
||||
"serviceAccountClientId": "mccn-api",
|
||||
"clientRoleMappings": {
|
||||
"realm-management": [
|
||||
{
|
||||
"name": "manage-users"
|
||||
},
|
||||
{
|
||||
"name": "view-users"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Mccn.Modules.Hello.Presentation;
|
||||
|
||||
public static class AssemblyReference
|
||||
{
|
||||
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Mccn.Common.Presentation.Endpoints;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Modules.Hello.Presentation;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddHelloPresentationServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddEndpoints(typeof(DependencyInjection).Assembly);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using Mccn.Common.Presentation.Endpoints;
|
||||
using Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Mccn.Modules.Hello.Presentation.Hello;
|
||||
|
||||
internal sealed class GetHello : IEndpoint
|
||||
{
|
||||
public void MapEndpoint(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("hello", async (string? name, ISender sender) =>
|
||||
{
|
||||
SayHelloQuery query = new(name);
|
||||
Result<HelloResponse> result = await sender.Send(query);
|
||||
|
||||
return Results.Ok(result.Value);
|
||||
})
|
||||
.AllowAnonymous()
|
||||
.WithTags("Hello")
|
||||
.WithName("GetHello")
|
||||
.WithSummary("Say hello");
|
||||
|
||||
app.MapGet("hello/me", async (HttpContext context, ISender sender) =>
|
||||
{
|
||||
string name = context.User.FindFirst("given_name")?.Value
|
||||
?? context.User.FindFirst("name")?.Value
|
||||
?? "Authenticated User";
|
||||
|
||||
SayHelloQuery query = new(name);
|
||||
Result<HelloResponse> result = await sender.Send(query);
|
||||
|
||||
return Results.Ok(result.Value);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.WithTags("Hello")
|
||||
.WithName("GetHelloAuthenticated")
|
||||
.WithSummary("Say hello to the authenticated user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Modules.Hello.Application\Mccn.Modules.Hello.Application.csproj"/>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Presentation\Mccn.Common.Presentation.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.Application.Abstractions;
|
||||
|
||||
public interface IIdentityProviderService
|
||||
{
|
||||
Task<Result<string>> RegisterUserAsync(
|
||||
UserModel user,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record UserModel(
|
||||
string Email,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Password);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Mccn.Modules.Users.Application.Abstractions;
|
||||
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Mccn.Modules.Users.Application;
|
||||
|
||||
public static class AssemblyReference
|
||||
{
|
||||
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj"/>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.Application.Users.GetProfile;
|
||||
|
||||
public sealed record GetUserProfileQuery(Guid UserId) : IQuery<UserProfileResponse>;
|
||||
@@ -0,0 +1,30 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
|
||||
namespace Mccn.Modules.Users.Application.Users.GetProfile;
|
||||
|
||||
internal sealed class GetUserProfileQueryHandler : IQueryHandler<GetUserProfileQuery, UserProfileResponse>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
|
||||
public GetUserProfileQueryHandler(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileResponse>> Handle(
|
||||
GetUserProfileQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
User? user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||
|
||||
if (user is null) return Result.Failure<UserProfileResponse>(UserErrors.NotFound(request.UserId));
|
||||
|
||||
return new UserProfileResponse(
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.FirstName,
|
||||
user.LastName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Mccn.Modules.Users.Application.Users.GetProfile;
|
||||
|
||||
public sealed record UserProfileResponse(
|
||||
Guid Id,
|
||||
string Email,
|
||||
string FirstName,
|
||||
string LastName);
|
||||
@@ -0,0 +1,9 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.Application.Users.Register;
|
||||
|
||||
public sealed record RegisterUserCommand(
|
||||
string Email,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Password) : ICommand<Guid>;
|
||||
@@ -0,0 +1,46 @@
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using Mccn.Modules.Users.Application.Abstractions;
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
|
||||
namespace Mccn.Modules.Users.Application.Users.Register;
|
||||
|
||||
internal sealed class RegisterUserCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IIdentityProviderService identityProviderService,
|
||||
IUnitOfWork unitOfWork)
|
||||
: ICommandHandler<RegisterUserCommand, Guid>
|
||||
{
|
||||
public async Task<Result<Guid>> Handle(
|
||||
RegisterUserCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await userRepository.ExistsByEmailAsync(request.Email, cancellationToken))
|
||||
return Result.Failure<Guid>(UserErrors.EmailAlreadyExists(request.Email));
|
||||
|
||||
UserModel userModel = new(
|
||||
request.Email,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.Password);
|
||||
|
||||
Result<string> identityResult = await identityProviderService.RegisterUserAsync(
|
||||
userModel,
|
||||
cancellationToken);
|
||||
|
||||
if (identityResult.IsFailure) return Result.Failure<Guid>(identityResult.Error);
|
||||
|
||||
User user = User.Create(
|
||||
Guid.NewGuid(),
|
||||
request.Email,
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
identityResult.Value);
|
||||
|
||||
userRepository.Add(user);
|
||||
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return user.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Mccn.Modules.Users.Application.Users.Register;
|
||||
|
||||
internal sealed class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
|
||||
{
|
||||
public RegisterUserCommandValidator()
|
||||
{
|
||||
RuleFor(c => c.Email).NotEmpty().EmailAddress();
|
||||
RuleFor(c => c.FirstName).NotEmpty().MaximumLength(100);
|
||||
RuleFor(c => c.LastName).NotEmpty().MaximumLength(100);
|
||||
RuleFor(c => c.Password).NotEmpty().MinimumLength(8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Reflection;
|
||||
using Mccn.Modules.Users.Application;
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
using Mccn.Modules.Users.Infrastructure;
|
||||
|
||||
namespace Mccn.Modules.Users.ArchitectureTests.Abstractions;
|
||||
|
||||
public abstract class BaseTest
|
||||
{
|
||||
protected static readonly Assembly ApplicationAssembly =
|
||||
AssemblyReference.Assembly;
|
||||
|
||||
protected static readonly Assembly DomainAssembly =
|
||||
typeof(User).Assembly;
|
||||
|
||||
protected static readonly Assembly InfrastructureAssembly =
|
||||
typeof(DependencyInjection).Assembly;
|
||||
|
||||
protected static readonly Assembly PresentationAssembly =
|
||||
Presentation.AssemblyReference.Assembly;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using FluentAssertions;
|
||||
using NetArchTest.Rules;
|
||||
|
||||
namespace Mccn.Modules.Users.ArchitectureTests.Abstractions;
|
||||
|
||||
internal static class TestResultExtensions
|
||||
{
|
||||
internal static void ShouldBeSuccessful(this TestResult testResult)
|
||||
{
|
||||
testResult.FailingTypes?.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using FluentValidation;
|
||||
using Mccn.Common.Application.Abstractions;
|
||||
using Mccn.Modules.Users.ArchitectureTests.Abstractions;
|
||||
using NetArchTest.Rules;
|
||||
|
||||
namespace Mccn.Modules.Users.ArchitectureTests.Application;
|
||||
|
||||
public class ApplicationTests : BaseTest
|
||||
{
|
||||
[Fact]
|
||||
public void Command_Should_BeSealed()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(ICommand))
|
||||
.Or()
|
||||
.ImplementInterface(typeof(ICommand<>))
|
||||
.Should()
|
||||
.BeSealed()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_ShouldHave_NameEndingWith_Command()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(ICommand))
|
||||
.Or()
|
||||
.ImplementInterface(typeof(ICommand<>))
|
||||
.Should()
|
||||
.HaveNameEndingWith("Command")
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandHandler_Should_NotBePublic()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(ICommandHandler<>))
|
||||
.Or()
|
||||
.ImplementInterface(typeof(ICommandHandler<,>))
|
||||
.Should()
|
||||
.NotBePublic()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandHandler_Should_BeSealed()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(ICommandHandler<>))
|
||||
.Or()
|
||||
.ImplementInterface(typeof(ICommandHandler<,>))
|
||||
.Should()
|
||||
.BeSealed()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandHandler_ShouldHave_NameEndingWith_CommandHandler()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(ICommandHandler<>))
|
||||
.Or()
|
||||
.ImplementInterface(typeof(ICommandHandler<,>))
|
||||
.Should()
|
||||
.HaveNameEndingWith("CommandHandler")
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_Should_BeSealed()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQuery<>))
|
||||
.Should()
|
||||
.BeSealed()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Query_ShouldHave_NameEndingWith_Query()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQuery<>))
|
||||
.Should()
|
||||
.HaveNameEndingWith("Query")
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryHandler_Should_NotBePublic()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQueryHandler<,>))
|
||||
.Should()
|
||||
.NotBePublic()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryHandler_Should_BeSealed()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQueryHandler<,>))
|
||||
.Should()
|
||||
.BeSealed()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QueryHandler_ShouldHave_NameEndingWith_QueryHandler()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.ImplementInterface(typeof(IQueryHandler<,>))
|
||||
.Should()
|
||||
.HaveNameEndingWith("QueryHandler")
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_Should_NotBePublic()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.Inherit(typeof(AbstractValidator<>))
|
||||
.Should()
|
||||
.NotBePublic()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_Should_BeSealed()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.Inherit(typeof(AbstractValidator<>))
|
||||
.Should()
|
||||
.BeSealed()
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_ShouldHave_NameEndingWith_Validator()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.That()
|
||||
.Inherit(typeof(AbstractValidator<>))
|
||||
.Should()
|
||||
.HaveNameEndingWith("Validator")
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Reflection;
|
||||
using FluentAssertions;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using Mccn.Modules.Users.ArchitectureTests.Abstractions;
|
||||
using NetArchTest.Rules;
|
||||
|
||||
namespace Mccn.Modules.Users.ArchitectureTests.Domain;
|
||||
|
||||
public class DomainTests : BaseTest
|
||||
{
|
||||
[Fact]
|
||||
public void Entities_ShouldHave_PrivateParameterlessConstructor()
|
||||
{
|
||||
IEnumerable<Type> entityTypes = Types.InAssembly(DomainAssembly)
|
||||
.That()
|
||||
.Inherit(typeof(Entity))
|
||||
.GetTypes();
|
||||
|
||||
List<Type> failingTypes = new();
|
||||
foreach (Type entityType in entityTypes)
|
||||
{
|
||||
ConstructorInfo[] constructors = entityType.GetConstructors(
|
||||
BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
|
||||
if (!constructors.Any(c => c.IsPrivate && c.GetParameters().Length == 0)) failingTypes.Add(entityType);
|
||||
}
|
||||
|
||||
failingTypes.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Entities_ShouldOnlyHave_PrivateConstructors()
|
||||
{
|
||||
IEnumerable<Type> entityTypes = Types.InAssembly(DomainAssembly)
|
||||
.That()
|
||||
.Inherit(typeof(Entity))
|
||||
.GetTypes();
|
||||
|
||||
List<Type> failingTypes = new();
|
||||
foreach (Type entityType in entityTypes)
|
||||
{
|
||||
ConstructorInfo[] constructors = entityType.GetConstructors(
|
||||
BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (constructors.Any()) failingTypes.Add(entityType);
|
||||
}
|
||||
|
||||
failingTypes.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Mccn.Modules.Users.ArchitectureTests.Abstractions;
|
||||
using NetArchTest.Rules;
|
||||
|
||||
namespace Mccn.Modules.Users.ArchitectureTests.Layers;
|
||||
|
||||
public class LayerTests : BaseTest
|
||||
{
|
||||
[Fact]
|
||||
public void DomainLayer_ShouldNotHaveDependencyOn_ApplicationLayer()
|
||||
{
|
||||
Types.InAssembly(DomainAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(ApplicationAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DomainLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
|
||||
{
|
||||
Types.InAssembly(DomainAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplicationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplicationLayer_ShouldNotHaveDependencyOn_PresentationLayer()
|
||||
{
|
||||
Types.InAssembly(ApplicationAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(PresentationAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresentationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer()
|
||||
{
|
||||
Types.InAssembly(PresentationAssembly)
|
||||
.Should()
|
||||
.NotHaveDependencyOn(InfrastructureAssembly.GetName().Name)
|
||||
.GetResult()
|
||||
.ShouldBeSuccessful();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="NetArchTest.Rules" Version="1.3.2"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Modules.Users.Infrastructure\Mccn.Modules.Users.Infrastructure.csproj"/>
|
||||
<ProjectReference Include="..\Mccn.Modules.Users.Presentation\Mccn.Modules.Users.Presentation.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Mccn.Modules.Users.Domain.Users;
|
||||
|
||||
public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
|
||||
Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken = default);
|
||||
void Add(User user);
|
||||
}
|
||||
32
src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs
Normal file
32
src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.Domain.Users;
|
||||
|
||||
public sealed class User : Entity
|
||||
{
|
||||
private User()
|
||||
{
|
||||
}
|
||||
|
||||
public string Email { get; private set; } = string.Empty;
|
||||
public string FirstName { get; private set; } = string.Empty;
|
||||
public string LastName { get; private set; } = string.Empty;
|
||||
public string IdentityId { get; private set; } = string.Empty;
|
||||
|
||||
public static User Create(
|
||||
Guid id,
|
||||
string email,
|
||||
string firstName,
|
||||
string lastName,
|
||||
string identityId)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = id,
|
||||
Email = email,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
IdentityId = identityId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.Domain.Users;
|
||||
|
||||
public static class UserErrors
|
||||
{
|
||||
public static Error NotFound(Guid userId)
|
||||
{
|
||||
return Error.NotFound("Users.NotFound", $"The user with the ID '{userId}' was not found.");
|
||||
}
|
||||
|
||||
public static Error EmailAlreadyExists(string email)
|
||||
{
|
||||
return Error.Conflict("Users.EmailAlreadyExists", $"A user with email '{email}' already exists.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Mccn.Modules.Users.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(UsersDbContext))]
|
||||
[Migration("20260315075515_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("users")
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("IdentityId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("identity_id");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_email");
|
||||
|
||||
b.HasIndex("IdentityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_identity_id");
|
||||
|
||||
b.ToTable("users", "users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "users");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "users",
|
||||
schema: "users",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
email = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||
first_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
last_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
identity_id = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_users", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_users_email",
|
||||
schema: "users",
|
||||
table: "users",
|
||||
column: "email",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_users_identity_id",
|
||||
schema: "users",
|
||||
table: "users",
|
||||
column: "identity_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "users",
|
||||
schema: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Mccn.Modules.Users.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
|
||||
{
|
||||
[DbContext(typeof(UsersDbContext))]
|
||||
partial class UsersDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("users")
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("first_name");
|
||||
|
||||
b.Property<string>("IdentityId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("identity_id");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("last_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_email");
|
||||
|
||||
b.HasIndex("IdentityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_identity_id");
|
||||
|
||||
b.ToTable("users", "users");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Mccn.Modules.Users.Infrastructure.Database;
|
||||
|
||||
internal static class Schemas
|
||||
{
|
||||
internal const string Users = "users";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Database;
|
||||
|
||||
public sealed class UsersDbContext : DbContext
|
||||
{
|
||||
public UsersDbContext(DbContextOptions<UsersDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<User> Users { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(Schemas.Users);
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(UsersDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Mccn.Modules.Users.Application.Abstractions;
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
using Mccn.Modules.Users.Infrastructure.Database;
|
||||
using Mccn.Modules.Users.Infrastructure.Keycloak;
|
||||
using Mccn.Modules.Users.Infrastructure.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddUsersModule(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<UsersDbContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(
|
||||
configuration.GetConnectionString("Database"),
|
||||
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
|
||||
.UseSnakeCaseNamingConvention();
|
||||
});
|
||||
|
||||
services.Configure<KeycloakOptions>(
|
||||
configuration.GetSection(KeycloakOptions.SectionName));
|
||||
|
||||
services.AddTransient<KeycloakAuthDelegatingHandler>();
|
||||
|
||||
services.AddHttpClient<IIdentityProviderService, KeycloakClient>((sp, client) =>
|
||||
{
|
||||
KeycloakOptions keycloakOptions = configuration
|
||||
.GetSection(KeycloakOptions.SectionName)
|
||||
.Get<KeycloakOptions>()!;
|
||||
client.BaseAddress = new Uri(keycloakOptions.AdminUrl);
|
||||
})
|
||||
.AddHttpMessageHandler<KeycloakAuthDelegatingHandler>();
|
||||
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Keycloak;
|
||||
|
||||
internal sealed class KeycloakAuthDelegatingHandler : DelegatingHandler
|
||||
{
|
||||
private readonly KeycloakOptions _options;
|
||||
|
||||
public KeycloakAuthDelegatingHandler(IOptions<KeycloakOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string token = await GetAccessTokenAsync(cancellationToken);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using HttpClient client = new();
|
||||
|
||||
FormUrlEncodedContent content = new(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = _options.ClientId,
|
||||
["client_secret"] = _options.ClientSecret
|
||||
});
|
||||
|
||||
HttpResponseMessage response = await client.PostAsync(_options.TokenUrl, content, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using JsonDocument doc = await JsonDocument.ParseAsync(
|
||||
await response.Content.ReadAsStreamAsync(cancellationToken),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
return doc.RootElement.GetProperty("access_token").GetString()!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Net.Http.Json;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using Mccn.Modules.Users.Application.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Keycloak;
|
||||
|
||||
internal sealed class KeycloakClient : IIdentityProviderService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public KeycloakClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public async Task<Result<string>> RegisterUserAsync(
|
||||
UserModel user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userRepresentation = new
|
||||
{
|
||||
username = user.Email,
|
||||
email = user.Email,
|
||||
firstName = user.FirstName,
|
||||
lastName = user.LastName,
|
||||
enabled = true,
|
||||
credentials = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "password",
|
||||
value = user.Password,
|
||||
temporary = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponseMessage response = await _httpClient.PostAsJsonAsync(
|
||||
"users",
|
||||
userRepresentation,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return Result.Failure<string>(
|
||||
Error.Failure("Keycloak.Register",
|
||||
$"Keycloak returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}"));
|
||||
}
|
||||
|
||||
// Extract user ID from Location header.
|
||||
string? locationHeader = response.Headers.Location?.ToString();
|
||||
if (string.IsNullOrEmpty(locationHeader))
|
||||
return Result.Failure<string>(
|
||||
Error.Failure("Keycloak.Register", "Could not determine new user identity ID."));
|
||||
|
||||
string identityId = locationHeader.Split('/').Last();
|
||||
return identityId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Mccn.Modules.Users.Infrastructure.Keycloak;
|
||||
|
||||
public sealed class KeycloakOptions
|
||||
{
|
||||
public const string SectionName = "Users:Keycloak";
|
||||
|
||||
public string AdminUrl { get; init; } = string.Empty;
|
||||
public string TokenUrl { get; init; } = string.Empty;
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
public string ClientSecret { get; init; } = string.Empty;
|
||||
public string PublicClientId { get; init; } = "mccn-swagger";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj" />
|
||||
<ProjectReference Include="..\Mccn.Modules.Users.Application\Mccn.Modules.Users.Application.csproj" />
|
||||
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Mccn.Modules.Users.Application.Abstractions;
|
||||
using Mccn.Modules.Users.Infrastructure.Database;
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Users;
|
||||
|
||||
internal sealed class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private readonly UsersDbContext _context;
|
||||
|
||||
public UnitOfWork(UsersDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Users;
|
||||
|
||||
internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> builder)
|
||||
{
|
||||
builder.HasKey(u => u.Id);
|
||||
|
||||
builder.Property(u => u.Email)
|
||||
.IsRequired()
|
||||
.HasMaxLength(300);
|
||||
|
||||
builder.HasIndex(u => u.Email).IsUnique();
|
||||
|
||||
builder.Property(u => u.FirstName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(u => u.LastName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(u => u.IdentityId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.HasIndex(u => u.IdentityId).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
using Mccn.Modules.Users.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Mccn.Modules.Users.Infrastructure.Users;
|
||||
|
||||
internal sealed class UserRepository : IUserRepository
|
||||
{
|
||||
private readonly UsersDbContext _context;
|
||||
|
||||
public UserRepository(UsersDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Users
|
||||
.AnyAsync(u => u.Email == email, cancellationToken);
|
||||
}
|
||||
|
||||
public void Add(User user)
|
||||
{
|
||||
_context.Users.Add(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bogus;
|
||||
using Mccn.Modules.Users.Infrastructure.Database;
|
||||
using Mccn.Modules.Users.Infrastructure.Keycloak;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
|
||||
|
||||
[Collection(nameof(IntegrationTestCollection))]
|
||||
public abstract class BaseIntegrationTest : IDisposable
|
||||
{
|
||||
protected static readonly Faker Faker = new();
|
||||
private readonly KeycloakOptions _keycloakOptions;
|
||||
|
||||
private readonly IServiceScope _scope;
|
||||
protected readonly UsersDbContext DbContext;
|
||||
protected readonly HttpClient HttpClient;
|
||||
protected readonly ISender Sender;
|
||||
|
||||
protected BaseIntegrationTest(IntegrationTestWebAppFactory factory)
|
||||
{
|
||||
_scope = factory.Services.CreateScope();
|
||||
HttpClient = factory.CreateClient();
|
||||
Sender = _scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
DbContext = _scope.ServiceProvider.GetRequiredService<UsersDbContext>();
|
||||
_keycloakOptions = _scope.ServiceProvider
|
||||
.GetRequiredService<IOptions<KeycloakOptions>>().Value;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scope.Dispose();
|
||||
}
|
||||
|
||||
protected async Task CleanDatabaseAsync()
|
||||
{
|
||||
await DbContext.Database.ExecuteSqlRawAsync("DELETE FROM users.users;");
|
||||
}
|
||||
|
||||
protected async Task<string> GetAccessTokenAsync(string email, string password)
|
||||
{
|
||||
using HttpClient client = new();
|
||||
|
||||
KeyValuePair<string, string>[] parameters = new KeyValuePair<string, string>[]
|
||||
{
|
||||
new("client_id", _keycloakOptions.PublicClientId),
|
||||
new("grant_type", "password"),
|
||||
new("scope", "openid"),
|
||||
new("username", email),
|
||||
new("password", password)
|
||||
};
|
||||
|
||||
using FormUrlEncodedContent content = new(parameters);
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, new Uri(_keycloakOptions.TokenUrl));
|
||||
request.Content = content;
|
||||
|
||||
using HttpResponseMessage response = await client.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
AuthToken? authToken = await response.Content.ReadFromJsonAsync<AuthToken>();
|
||||
return authToken!.AccessToken;
|
||||
}
|
||||
|
||||
internal sealed class AuthToken
|
||||
{
|
||||
[JsonPropertyName("access_token")] public string AccessToken { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
|
||||
|
||||
[CollectionDefinition(nameof(IntegrationTestCollection))]
|
||||
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>;
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Testcontainers.Keycloak;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Testcontainers.Redis;
|
||||
|
||||
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public class IntegrationTestWebAppFactory : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17")
|
||||
.WithDatabase("mccn")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
.Build();
|
||||
|
||||
private readonly RedisContainer _redisContainer = new RedisBuilder()
|
||||
.WithImage("redis:latest")
|
||||
.Build();
|
||||
|
||||
private readonly KeycloakContainer _keycloakContainer = new KeycloakBuilder()
|
||||
.WithImage("quay.io/keycloak/keycloak:26.5.1")
|
||||
.WithResourceMapping(
|
||||
new FileInfo("mccn-realm-export.json"),
|
||||
new FileInfo("/opt/keycloak/data/import/realm.json"))
|
||||
.WithCommand("--import-realm")
|
||||
.Build();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
string keycloakAddress = _keycloakContainer.GetBaseAddress();
|
||||
string keycloakRealmUrl = $"{keycloakAddress}realms/mccn";
|
||||
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString());
|
||||
Environment.SetEnvironmentVariable("ConnectionStrings:Cache", _redisContainer.GetConnectionString());
|
||||
Environment.SetEnvironmentVariable("Authentication:MetadataAddress",
|
||||
$"{keycloakRealmUrl}/.well-known/openid-configuration");
|
||||
Environment.SetEnvironmentVariable("Users:Keycloak:AdminUrl", $"{keycloakAddress}admin/realms/mccn/");
|
||||
Environment.SetEnvironmentVariable("Users:Keycloak:TokenUrl",
|
||||
$"{keycloakRealmUrl}/protocol/openid-connect/token");
|
||||
Environment.SetEnvironmentVariable("Users:Keycloak:ClientId", "mccn-api");
|
||||
Environment.SetEnvironmentVariable("Users:Keycloak:ClientSecret", "change-this-secret");
|
||||
Environment.SetEnvironmentVariable("Users:Keycloak:PublicClientId", "mccn-swagger");
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _dbContainer.StartAsync();
|
||||
await _redisContainer.StartAsync();
|
||||
await _keycloakContainer.StartAsync();
|
||||
}
|
||||
|
||||
public new async Task DisposeAsync()
|
||||
{
|
||||
await _dbContainer.StopAsync();
|
||||
await _redisContainer.StopAsync();
|
||||
await _keycloakContainer.StopAsync();
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bogus" Version="35.6.5"/>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="Testcontainers.Keycloak" Version="4.11.0"/>
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0"/>
|
||||
<PackageReference Include="Testcontainers.Redis" Version="4.11.0"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\API\Mccn.Api\Mccn.Api.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="mccn-realm-export.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,47 @@
|
||||
using FluentAssertions;
|
||||
using Mccn.Common.Domain.Abstractions;
|
||||
using Mccn.Modules.Users.Application.Users.GetProfile;
|
||||
using Mccn.Modules.Users.Application.Users.Register;
|
||||
using Mccn.Modules.Users.Domain.Users;
|
||||
using Mccn.Modules.Users.IntegrationTests.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.IntegrationTests.Users;
|
||||
|
||||
public class GetUserProfileTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Should_ReturnError_WhenUserDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
Guid userId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
Result<UserProfileResponse> result = await Sender.Send(new GetUserProfileQuery(userId));
|
||||
|
||||
// Assert
|
||||
result.IsFailure.Should().BeTrue();
|
||||
result.Error.Should().Be(UserErrors.NotFound(userId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ReturnProfile_WhenUserExists()
|
||||
{
|
||||
// Arrange
|
||||
RegisterUserCommand command = new(
|
||||
Faker.Internet.Email(),
|
||||
Faker.Name.FirstName(),
|
||||
Faker.Name.LastName(),
|
||||
"ValidPass1!");
|
||||
|
||||
Result<Guid> registerResult = await Sender.Send(command);
|
||||
Guid userId = registerResult.Value;
|
||||
|
||||
// Act
|
||||
Result<UserProfileResponse> result = await Sender.Send(new GetUserProfileQuery(userId));
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Value.Should().NotBeNull();
|
||||
result.Value.Id.Should().Be(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Mccn.Modules.Users.IntegrationTests.Abstractions;
|
||||
|
||||
namespace Mccn.Modules.Users.IntegrationTests.Users;
|
||||
|
||||
public class RegisterUserTests : BaseIntegrationTest
|
||||
{
|
||||
public static readonly TheoryData<string, string, string, string> InvalidRequests = new()
|
||||
{
|
||||
{ "", Faker.Internet.Password(), Faker.Name.FirstName(), Faker.Name.LastName() },
|
||||
{ Faker.Internet.Email(), "", Faker.Name.FirstName(), Faker.Name.LastName() },
|
||||
{ Faker.Internet.Email(), "short", Faker.Name.FirstName(), Faker.Name.LastName() },
|
||||
{ Faker.Internet.Email(), Faker.Internet.Password(), "", Faker.Name.LastName() },
|
||||
{ Faker.Internet.Email(), Faker.Internet.Password(), Faker.Name.FirstName(), "" }
|
||||
};
|
||||
|
||||
public RegisterUserTests(IntegrationTestWebAppFactory factory)
|
||||
: base(factory)
|
||||
{
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRequests))]
|
||||
public async Task Should_ReturnBadRequest_WhenRequestIsNotValid(
|
||||
string email, string password, string firstName, string lastName)
|
||||
{
|
||||
// Arrange
|
||||
var request = new { Email = email, FirstName = firstName, LastName = lastName, Password = password };
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await HttpClient.PostAsJsonAsync("users/register", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ReturnCreated_WhenRequestIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var request = new
|
||||
{
|
||||
Email = "register@test.com",
|
||||
FirstName = Faker.Name.FirstName(),
|
||||
LastName = Faker.Name.LastName(),
|
||||
Password = "ValidPass1!"
|
||||
};
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await HttpClient.PostAsJsonAsync("users/register", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_ReturnAccessToken_WhenUserIsRegistered()
|
||||
{
|
||||
// Arrange
|
||||
const string email = "token@test.com";
|
||||
const string password = "ValidPass1!";
|
||||
|
||||
var request = new
|
||||
{
|
||||
Email = email,
|
||||
FirstName = Faker.Name.FirstName(),
|
||||
LastName = Faker.Name.LastName(),
|
||||
Password = password
|
||||
};
|
||||
|
||||
await HttpClient.PostAsJsonAsync("users/register", request);
|
||||
|
||||
// Act
|
||||
string accessToken = await GetAccessTokenAsync(email, password);
|
||||
|
||||
// Assert
|
||||
accessToken.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"realm": "mccn",
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"registrationAllowed": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"failureFactor": 30,
|
||||
"accessTokenLifespan": 1800,
|
||||
"accessTokenLifespanForImplicitFlow": 900,
|
||||
"ssoSessionIdleTimeout": 1800,
|
||||
"ssoSessionMaxLifespan": 36000,
|
||||
"offlineSessionIdleTimeout": 2592000,
|
||||
"clients": [
|
||||
{
|
||||
"clientId": "mccn-api",
|
||||
"name": "Mccn API",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "change-this-secret",
|
||||
"serviceAccountsEnabled": true,
|
||||
"authorizationServicesEnabled": false,
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false
|
||||
},
|
||||
{
|
||||
"clientId": "mccn-swagger",
|
||||
"name": "Mccn Swagger UI",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"protocol": "openid-connect",
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": true,
|
||||
"redirectUris": [
|
||||
"http://localhost:5000/*",
|
||||
"http://localhost:5001/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:5000",
|
||||
"http://localhost:5001"
|
||||
]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
"realm": [
|
||||
{
|
||||
"name": "user",
|
||||
"description": "Regular user"
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Administrator"
|
||||
}
|
||||
]
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "service-account-mccn-api",
|
||||
"enabled": true,
|
||||
"serviceAccountClientId": "mccn-api",
|
||||
"clientRoleMappings": {
|
||||
"realm-management": [
|
||||
{
|
||||
"name": "manage-users"
|
||||
},
|
||||
{
|
||||
"name": "view-users"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Mccn.Modules.Users.Presentation;
|
||||
|
||||
public static class AssemblyReference
|
||||
{
|
||||
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Mccn.Common.Presentation.Endpoints;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Mccn.Modules.Users.Presentation;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddUsersPresentationServices(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
services.AddEndpoints(typeof(DependencyInjection).Assembly);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user