Merge pull request #141 from aaronpo97/refactor/add-infrastructure-project-dir

Refactor/add infrastructure project dir
This commit is contained in:
Aaron Po
2026-02-12 18:26:17 -05:00
committed by GitHub
55 changed files with 1664 additions and 1410 deletions

3
.gitignore vendored
View File

@@ -483,9 +483,6 @@ FodyWeavers.xsd
*.feature.cs *.feature.cs
database
.env .env
.env.dev .env.dev
.env.test .env.test

View File

@@ -2,46 +2,6 @@
A social platform for craft beer enthusiasts to discover breweries, share reviews, and connect with fellow beer lovers. A social platform for craft beer enthusiasts to discover breweries, share reviews, and connect with fellow beer lovers.
## Table of Contents
- [Project Status](#project-status)
- [Repository Structure](#repository-structure)
- [Technology Stack](#technology-stack)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Quick Start (Development Environment)](#quick-start-development-environment)
- [Manual Setup (Without Docker)](#manual-setup-without-docker)
- [Environment Variables](#environment-variables)
- [Overview](#overview)
- [Backend Variables (.NET API)](#backend-variables-net-api)
- [Frontend Variables (Next.js)](#frontend-variables-nextjs)
- [Docker Variables](#docker-variables)
- [External Services](#external-services)
- [Generating Secrets](#generating-secrets)
- [Environment File Structure](#environment-file-structure)
- [Variable Reference Table](#variable-reference-table)
- [Testing](#testing)
- [Database Schema](#database-schema)
- [Authentication & Security](#authentication--security)
- [Architecture Patterns](#architecture-patterns)
- [Docker & Containerization](#docker--containerization)
- [Container Architecture](#container-architecture)
- [Docker Compose Environments](#docker-compose-environments)
- [Service Dependencies](#service-dependencies)
- [Health Checks](#health-checks)
- [Volumes](#volumes)
- [Networks](#networks)
- [Environment Variables](#environment-variables)
- [Container Lifecycle](#container-lifecycle)
- [Docker Tips & Troubleshooting](#docker-tips--troubleshooting)
- [Roadmap](#roadmap)
- [License](#license)
- [Contact & Support](#contact--support)
---
## Project Status ## Project Status
This project is in active development, transitioning from a full-stack Next.js application to a **multi-project monorepo** with: This project is in active development, transitioning from a full-stack Next.js application to a **multi-project monorepo** with:
@@ -49,10 +9,10 @@ This project is in active development, transitioning from a full-stack Next.js a
- **Frontend**: Next.js with TypeScript - **Frontend**: Next.js with TypeScript
- **Architecture**: SQL-first approach using stored procedures - **Architecture**: SQL-first approach using stored procedures
**Current State** (February 2025): **Current State** (February 2026):
- Core authentication and user management APIs functional - Core authentication and user management APIs functional
- Database schema and migrations established - Database schema and migrations established
- Repository and service layers implemented - Domain, Infrastructure, Repository, and Service layers implemented
- Frontend integration with .NET API in progress - Frontend integration with .NET API in progress
- Migrating remaining features from Next.js serverless functions - Migrating remaining features from Next.js serverless functions
@@ -65,18 +25,26 @@ This project is in active development, transitioning from a full-stack Next.js a
``` ```
src/Core/ src/Core/
├── API/ ├── API/
│ ├── API.Core/ # ASP.NET Core Web API with Swagger/OpenAPI │ ├── API.Core/ # ASP.NET Core Web API with Swagger/OpenAPI
│ └── API.Specs/ # Integration tests using Reqnroll (BDD) │ └── API.Specs/ # Integration tests using Reqnroll (BDD)
├── Database/ ├── Database/
│ ├── Database.Migrations/ # DbUp migrations (embedded SQL scripts) │ ├── Database.Migrations/ # DbUp migrations (embedded SQL scripts)
│ └── Database.Seed/ # Database seeding for development/testing │ └── Database.Seed/ # Database seeding for development/testing
├── Repository/ ├── Domain/
── Repository.Core/ # Data access layer (stored procedure-based) ── Domain.csproj # Domain entities and models
└── Repository.Tests/ # Unit tests for repositories └── Entities/ # Core domain entities (UserAccount, UserCredential, etc.)
├── Infrastructure/
│ ├── Infrastructure.Jwt/ # JWT token generation and validation
│ ├── Infrastructure.PasswordHashing/ # Argon2id password hashing
│ └── Infrastructure.Repository/
│ ├── Infrastructure.Repository/ # Data access layer (stored procedure-based)
│ └── Infrastructure.Repository.Tests/ # Unit tests for repositories
└── Service/ └── Service/
└── Service.Core/ # Business logic layer └── Service.Core/ # Business logic layer
Website/ # Next.js frontend application Website/ # Next.js frontend application
misc/
└── raw-data/ # Sample data files (breweries, beers)
``` ```
### Key Components ### Key Components
@@ -86,6 +54,7 @@ Website/ # Next.js frontend application
- Controllers: `AuthController`, `UserController` - Controllers: `AuthController`, `UserController`
- Configured with Swagger UI for API exploration - Configured with Swagger UI for API exploration
- Health checks and structured logging - Health checks and structured logging
- Middleware for error handling and request processing
**Database Layer** **Database Layer**
- SQL Server with stored procedures for all data operations - SQL Server with stored procedures for all data operations
@@ -93,7 +62,19 @@ Website/ # Next.js frontend application
- Comprehensive schema including users, breweries, beers, locations, and social features - Comprehensive schema including users, breweries, beers, locations, and social features
- Seeders for development data (users, locations across US/Canada/Mexico) - Seeders for development data (users, locations across US/Canada/Mexico)
**Repository Layer** (`Repository.Core`) **Domain Layer** (`Domain`)
- Core business entities and models
- Entities: `UserAccount`, `UserCredential`, `UserVerification`
- Shared domain logic and value objects
- No external dependencies - pure domain model
**Infrastructure Layer**
- **Infrastructure.Jwt**: JWT token generation, validation, and configuration
- **Infrastructure.PasswordHashing**: Argon2id password hashing with configurable parameters
- **Infrastructure.Password**: Password utilities and validation
- **Infrastructure.Repository**: Repository pattern infrastructure and base classes
**Repository Layer** (`Infrastructure.Repository`)
- Abstraction over SQL Server using ADO.NET - Abstraction over SQL Server using ADO.NET
- `ISqlConnectionFactory` for connection management - `ISqlConnectionFactory` for connection management
- Repositories: `AuthRepository`, `UserAccountRepository` - Repositories: `AuthRepository`, `UserAccountRepository`
@@ -101,9 +82,9 @@ Website/ # Next.js frontend application
**Service Layer** (`Service.Core`) **Service Layer** (`Service.Core`)
- Business logic and orchestration - Business logic and orchestration
- Services: `AuthService`, `UserService`, `JwtService` - Services: `AuthService`, `UserService`
- Password hashing with Argon2id - Integration with infrastructure components
- JWT token generation - Transaction management and business rule enforcement
**Frontend** (`Website`) **Frontend** (`Website`)
- Next.js 14+ with TypeScript - Next.js 14+ with TypeScript
@@ -334,7 +315,7 @@ Provide a complete SQL Server connection string:
DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;" DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
``` ```
The connection factory checks for `DB_CONNECTION_STRING` first, then falls back to building from components. See [DefaultSqlConnectionFactory.cs](src/Core/Repository/Repository.Core/Sql/DefaultSqlConnectionFactory.cs). The connection factory checks for `DB_CONNECTION_STRING` first, then falls back to building from components. See [DefaultSqlConnectionFactory.cs](src/Core/Infrastructure/Infrastructure.Repository/Infrastructure.Repository/Sql/DefaultSqlConnectionFactory.cs).
#### JWT Authentication #### JWT Authentication
@@ -578,7 +559,7 @@ docker compose -f docker-compose.test.yaml up --abort-on-container-exit
This runs: This runs:
- **API.Specs** - BDD integration tests - **API.Specs** - BDD integration tests
- **Repository.Tests** - Unit tests for data access - **Infrastructure.Repository.Tests** - Unit tests for data access
Test results are output to `./test-results/`. Test results are output to `./test-results/`.
@@ -590,10 +571,10 @@ cd src/Core
dotnet test API/API.Specs/API.Specs.csproj dotnet test API/API.Specs/API.Specs.csproj
``` ```
**Unit Tests (Repository.Tests)** **Unit Tests (Infrastructure.Repository.Tests)**
```bash ```bash
cd src/Core cd src/Core
dotnet test Repository/Repository.Tests/Repository.Tests.csproj dotnet test Infrastructure/Infrastructure.Repository/Infrastructure.Repository.Tests/Repository.Tests.csproj
``` ```
### Test Features ### Test Features

View File

@@ -101,7 +101,7 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
build: build:
context: ./src/Core context: ./src/Core
dockerfile: Repository/Repository.Tests/Dockerfile dockerfile: Infrastructure/Infrastructure.Repository.Tests/Dockerfile
args: args:
BUILD_CONFIGURATION: Release BUILD_CONFIGURATION: Release
environment: environment:

View File

@@ -19,7 +19,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Repository\Repository.Core\Repository.Core.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Core\Service.Core.csproj" /> <ProjectReference Include="..\..\Service\Service.Core\Service.Core.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,9 +1,9 @@
using API.Core.Contracts.Auth; using API.Core.Contracts.Auth;
using API.Core.Contracts.Common; using API.Core.Contracts.Common;
using Domain.Core.Entities; using Domain.Entities;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Service.Core.Auth; using Service.Core.Auth;
using Service.Core.Jwt;
namespace API.Core.Controllers namespace API.Core.Controllers
{ {

View File

@@ -1,4 +1,4 @@
using Domain.Core.Entities; using Domain.Entities;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Service.Core.User; using Service.Core.User;

View File

@@ -9,7 +9,10 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"] COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"] COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"]
RUN dotnet restore "API/API.Core/API.Core.csproj" RUN dotnet restore "API/API.Core/API.Core.csproj"
COPY . . COPY . .

View File

@@ -0,0 +1,47 @@
using System.Net;
using System.Text.Json;
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Middleware;
public class ValidationExceptionHandlingMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ValidationException ex)
{
await HandleValidationExceptionAsync(context, ex);
}
}
private static Task HandleValidationExceptionAsync(HttpContext context, ValidationException exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
var errors = exception.Errors
.Select(e => e.ErrorMessage)
.ToList();
var message = errors.Count == 1
? errors[0]
: "Validation failed. " + string.Join(" ", errors);
var response = new ResponseBody
{
Message = message
};
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
return context.Response.WriteAsync(JsonSerializer.Serialize(response, jsonOptions));
}
}

View File

@@ -1,21 +1,45 @@
using FluentValidation; using FluentValidation;
using Repository.Core.Repositories.Auth; using FluentValidation.AspNetCore;
using Repository.Core.Repositories.UserAccount; using Infrastructure.Jwt;
using Repository.Core.Sql; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Microsoft.AspNetCore.Mvc;
using Service.Core.Auth; using Service.Core.Auth;
using Service.Core.Jwt;
using Service.Core.Password;
using Service.Core.User; using Service.Core.User;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState.Values
.SelectMany(v => v.Errors)
.Select(e => e.ErrorMessage)
.ToList();
var message = errors.Count == 1
? errors[0]
: string.Join(" ", errors);
var response = new
{
message
};
return new BadRequestObjectResult(response);
};
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
// Add FluentValidation // Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddFluentValidationAutoValidation();
// Add health checks // Add health checks
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
@@ -35,7 +59,7 @@ builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>(); builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IJwtService, JwtService>(); builder.Services.AddScoped<IJwtService, JwtService>();
builder.Services.AddScoped<IPasswordService, PasswordService>(); builder.Services.AddScoped<IPasswordInfra, Argon2Infrastructure>();
var app = builder.Build(); var app = builder.Build();
@@ -59,3 +83,6 @@ lifetime.ApplicationStopping.Register(() =>
}); });
app.Run(); app.Run();
// Make Program class accessible to test projects
public partial class Program { }

View File

@@ -3,7 +3,10 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"] COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"] COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"] COPY ["Service/Service.Core/Service.Core.csproj", "Service/Service.Core/"]
RUN dotnet restore "API/API.Specs/API.Specs.csproj" RUN dotnet restore "API/API.Specs/API.Specs.csproj"
COPY . . COPY . .

View File

@@ -52,7 +52,6 @@ Feature: User Registration
| Username | FirstName | LastName | Email | DateOfBirth | Password | | Username | FirstName | LastName | Email | DateOfBirth | Password |
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass | | newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
Then the response has HTTP status 400 Then the response has HTTP status 400
And the response JSON should have "message" equal "Password does not meet complexity requirements."
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement) Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
Given the API is running Given the API is running

View File

@@ -10,9 +10,11 @@
<Folder Name="/Domain/"> <Folder Name="/Domain/">
<Project Path="Domain/Domain.csproj" /> <Project Path="Domain/Domain.csproj" />
</Folder> </Folder>
<Folder Name="/Repository/"> <Folder Name="/Infrastructure/">
<Project Path="Repository/Repository.Core/Repository.Core.csproj" /> <Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Repository/Repository.Tests/Repository.Tests.csproj" /> <Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/Service/"> <Folder Name="/Service/">
<Project Path="Service/Service.Core/Service.Core.csproj" /> <Project Path="Service/Service.Core/Service.Core.csproj" />

View File

@@ -19,6 +19,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Repository\Repository.Core\Repository.Core.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,9 +1,8 @@
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace DBSeed namespace Database.Seed;
internal interface ISeeder
{ {
internal interface ISeeder Task SeedAsync(SqlConnection connection);
{
Task SeedAsync(SqlConnection connection);
}
} }

View File

@@ -1,328 +1,326 @@
using System.Data; using System.Data;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace DBSeed namespace Database.Seed;
internal class LocationSeeder : ISeeder
{ {
private static readonly IReadOnlyList<(
string CountryName,
string CountryCode
)> Countries =
[
("Canada", "CA"),
("Mexico", "MX"),
("United States", "US"),
];
internal class LocationSeeder : ISeeder private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
{ {
private static readonly IReadOnlyList<( get;
string CountryName, } =
string CountryCode [
)> Countries = ("Alabama", "US-AL", "US"),
[ ("Alaska", "US-AK", "US"),
("Canada", "CA"), ("Arizona", "US-AZ", "US"),
("Mexico", "MX"), ("Arkansas", "US-AR", "US"),
("United States", "US"), ("California", "US-CA", "US"),
]; ("Colorado", "US-CO", "US"),
("Connecticut", "US-CT", "US"),
("Delaware", "US-DE", "US"),
("Florida", "US-FL", "US"),
("Georgia", "US-GA", "US"),
("Hawaii", "US-HI", "US"),
("Idaho", "US-ID", "US"),
("Illinois", "US-IL", "US"),
("Indiana", "US-IN", "US"),
("Iowa", "US-IA", "US"),
("Kansas", "US-KS", "US"),
("Kentucky", "US-KY", "US"),
("Louisiana", "US-LA", "US"),
("Maine", "US-ME", "US"),
("Maryland", "US-MD", "US"),
("Massachusetts", "US-MA", "US"),
("Michigan", "US-MI", "US"),
("Minnesota", "US-MN", "US"),
("Mississippi", "US-MS", "US"),
("Missouri", "US-MO", "US"),
("Montana", "US-MT", "US"),
("Nebraska", "US-NE", "US"),
("Nevada", "US-NV", "US"),
("New Hampshire", "US-NH", "US"),
("New Jersey", "US-NJ", "US"),
("New Mexico", "US-NM", "US"),
("New York", "US-NY", "US"),
("North Carolina", "US-NC", "US"),
("North Dakota", "US-ND", "US"),
("Ohio", "US-OH", "US"),
("Oklahoma", "US-OK", "US"),
("Oregon", "US-OR", "US"),
("Pennsylvania", "US-PA", "US"),
("Rhode Island", "US-RI", "US"),
("South Carolina", "US-SC", "US"),
("South Dakota", "US-SD", "US"),
("Tennessee", "US-TN", "US"),
("Texas", "US-TX", "US"),
("Utah", "US-UT", "US"),
("Vermont", "US-VT", "US"),
("Virginia", "US-VA", "US"),
("Washington", "US-WA", "US"),
("West Virginia", "US-WV", "US"),
("Wisconsin", "US-WI", "US"),
("Wyoming", "US-WY", "US"),
("District of Columbia", "US-DC", "US"),
("Puerto Rico", "US-PR", "US"),
("U.S. Virgin Islands", "US-VI", "US"),
("Guam", "US-GU", "US"),
("Northern Mariana Islands", "US-MP", "US"),
("American Samoa", "US-AS", "US"),
("Ontario", "CA-ON", "CA"),
("Québec", "CA-QC", "CA"),
("Nova Scotia", "CA-NS", "CA"),
("New Brunswick", "CA-NB", "CA"),
("Manitoba", "CA-MB", "CA"),
("British Columbia", "CA-BC", "CA"),
("Prince Edward Island", "CA-PE", "CA"),
("Saskatchewan", "CA-SK", "CA"),
("Alberta", "CA-AB", "CA"),
("Newfoundland and Labrador", "CA-NL", "CA"),
("Northwest Territories", "CA-NT", "CA"),
("Yukon", "CA-YT", "CA"),
("Nunavut", "CA-NU", "CA"),
("Aguascalientes", "MX-AGU", "MX"),
("Baja California", "MX-BCN", "MX"),
("Baja California Sur", "MX-BCS", "MX"),
("Campeche", "MX-CAM", "MX"),
("Chiapas", "MX-CHP", "MX"),
("Chihuahua", "MX-CHH", "MX"),
("Coahuila de Zaragoza", "MX-COA", "MX"),
("Colima", "MX-COL", "MX"),
("Durango", "MX-DUR", "MX"),
("Guanajuato", "MX-GUA", "MX"),
("Guerrero", "MX-GRO", "MX"),
("Hidalgo", "MX-HID", "MX"),
("Jalisco", "MX-JAL", "MX"),
("México State", "MX-MEX", "MX"),
("Michoacán de Ocampo", "MX-MIC", "MX"),
("Morelos", "MX-MOR", "MX"),
("Nayarit", "MX-NAY", "MX"),
("Nuevo León", "MX-NLE", "MX"),
("Oaxaca", "MX-OAX", "MX"),
("Puebla", "MX-PUE", "MX"),
("Querétaro", "MX-QUE", "MX"),
("Quintana Roo", "MX-ROO", "MX"),
("San Luis Potosí", "MX-SLP", "MX"),
("Sinaloa", "MX-SIN", "MX"),
("Sonora", "MX-SON", "MX"),
("Tabasco", "MX-TAB", "MX"),
("Tamaulipas", "MX-TAM", "MX"),
("Tlaxcala", "MX-TLA", "MX"),
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
("Yucatán", "MX-YUC", "MX"),
("Zacatecas", "MX-ZAC", "MX"),
("Ciudad de México", "MX-CMX", "MX"),
];
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
[
("US-CA", "Los Angeles"),
("US-CA", "San Diego"),
("US-CA", "San Francisco"),
("US-CA", "Sacramento"),
("US-TX", "Houston"),
("US-TX", "Dallas"),
("US-TX", "Austin"),
("US-TX", "San Antonio"),
("US-FL", "Miami"),
("US-FL", "Orlando"),
("US-FL", "Tampa"),
("US-NY", "New York"),
("US-NY", "Buffalo"),
("US-NY", "Rochester"),
("US-IL", "Chicago"),
("US-IL", "Springfield"),
("US-PA", "Philadelphia"),
("US-PA", "Pittsburgh"),
("US-AZ", "Phoenix"),
("US-AZ", "Tucson"),
("US-CO", "Denver"),
("US-CO", "Colorado Springs"),
("US-MA", "Boston"),
("US-MA", "Worcester"),
("US-WA", "Seattle"),
("US-WA", "Spokane"),
("US-GA", "Atlanta"),
("US-GA", "Savannah"),
("US-NV", "Las Vegas"),
("US-NV", "Reno"),
("US-MI", "Detroit"),
("US-MI", "Grand Rapids"),
("US-MN", "Minneapolis"),
("US-MN", "Saint Paul"),
("US-OH", "Columbus"),
("US-OH", "Cleveland"),
("US-OR", "Portland"),
("US-OR", "Salem"),
("US-TN", "Nashville"),
("US-TN", "Memphis"),
("US-VA", "Richmond"),
("US-VA", "Virginia Beach"),
("US-MD", "Baltimore"),
("US-MD", "Frederick"),
("US-DC", "Washington"),
("US-UT", "Salt Lake City"),
("US-UT", "Provo"),
("US-LA", "New Orleans"),
("US-LA", "Baton Rouge"),
("US-KY", "Louisville"),
("US-KY", "Lexington"),
("US-IA", "Des Moines"),
("US-IA", "Cedar Rapids"),
("US-OK", "Oklahoma City"),
("US-OK", "Tulsa"),
("US-NE", "Omaha"),
("US-NE", "Lincoln"),
("US-MO", "Kansas City"),
("US-MO", "St. Louis"),
("US-NC", "Charlotte"),
("US-NC", "Raleigh"),
("US-SC", "Columbia"),
("US-SC", "Charleston"),
("US-WI", "Milwaukee"),
("US-WI", "Madison"),
("US-MN", "Duluth"),
("US-AK", "Anchorage"),
("US-HI", "Honolulu"),
("CA-ON", "Toronto"),
("CA-ON", "Ottawa"),
("CA-QC", "Montréal"),
("CA-QC", "Québec City"),
("CA-BC", "Vancouver"),
("CA-BC", "Victoria"),
("CA-AB", "Calgary"),
("CA-AB", "Edmonton"),
("CA-MB", "Winnipeg"),
("CA-NS", "Halifax"),
("CA-SK", "Saskatoon"),
("CA-SK", "Regina"),
("CA-NB", "Moncton"),
("CA-NB", "Saint John"),
("CA-PE", "Charlottetown"),
("CA-NL", "St. John's"),
("CA-ON", "Hamilton"),
("CA-ON", "London"),
("CA-QC", "Gatineau"),
("CA-QC", "Laval"),
("CA-BC", "Kelowna"),
("CA-AB", "Red Deer"),
("CA-MB", "Brandon"),
("MX-CMX", "Ciudad de México"),
("MX-JAL", "Guadalajara"),
("MX-NLE", "Monterrey"),
("MX-PUE", "Puebla"),
("MX-ROO", "Cancún"),
("MX-GUA", "Guanajuato"),
("MX-MIC", "Morelia"),
("MX-BCN", "Tijuana"),
("MX-JAL", "Zapopan"),
("MX-NLE", "San Nicolás"),
("MX-CAM", "Campeche"),
("MX-TAB", "Villahermosa"),
("MX-VER", "Veracruz"),
("MX-OAX", "Oaxaca"),
("MX-SLP", "San Luis Potosí"),
("MX-CHH", "Chihuahua"),
("MX-AGU", "Aguascalientes"),
("MX-MEX", "Toluca"),
("MX-COA", "Saltillo"),
("MX-BCS", "La Paz"),
("MX-NAY", "Tepic"),
("MX-ZAC", "Zacatecas"),
];
public async Task SeedAsync(SqlConnection connection)
{
foreach (var (countryName, countryCode) in Countries)
{ {
get; await CreateCountryAsync(connection, countryName, countryCode);
} =
[
("Alabama", "US-AL", "US"),
("Alaska", "US-AK", "US"),
("Arizona", "US-AZ", "US"),
("Arkansas", "US-AR", "US"),
("California", "US-CA", "US"),
("Colorado", "US-CO", "US"),
("Connecticut", "US-CT", "US"),
("Delaware", "US-DE", "US"),
("Florida", "US-FL", "US"),
("Georgia", "US-GA", "US"),
("Hawaii", "US-HI", "US"),
("Idaho", "US-ID", "US"),
("Illinois", "US-IL", "US"),
("Indiana", "US-IN", "US"),
("Iowa", "US-IA", "US"),
("Kansas", "US-KS", "US"),
("Kentucky", "US-KY", "US"),
("Louisiana", "US-LA", "US"),
("Maine", "US-ME", "US"),
("Maryland", "US-MD", "US"),
("Massachusetts", "US-MA", "US"),
("Michigan", "US-MI", "US"),
("Minnesota", "US-MN", "US"),
("Mississippi", "US-MS", "US"),
("Missouri", "US-MO", "US"),
("Montana", "US-MT", "US"),
("Nebraska", "US-NE", "US"),
("Nevada", "US-NV", "US"),
("New Hampshire", "US-NH", "US"),
("New Jersey", "US-NJ", "US"),
("New Mexico", "US-NM", "US"),
("New York", "US-NY", "US"),
("North Carolina", "US-NC", "US"),
("North Dakota", "US-ND", "US"),
("Ohio", "US-OH", "US"),
("Oklahoma", "US-OK", "US"),
("Oregon", "US-OR", "US"),
("Pennsylvania", "US-PA", "US"),
("Rhode Island", "US-RI", "US"),
("South Carolina", "US-SC", "US"),
("South Dakota", "US-SD", "US"),
("Tennessee", "US-TN", "US"),
("Texas", "US-TX", "US"),
("Utah", "US-UT", "US"),
("Vermont", "US-VT", "US"),
("Virginia", "US-VA", "US"),
("Washington", "US-WA", "US"),
("West Virginia", "US-WV", "US"),
("Wisconsin", "US-WI", "US"),
("Wyoming", "US-WY", "US"),
("District of Columbia", "US-DC", "US"),
("Puerto Rico", "US-PR", "US"),
("U.S. Virgin Islands", "US-VI", "US"),
("Guam", "US-GU", "US"),
("Northern Mariana Islands", "US-MP", "US"),
("American Samoa", "US-AS", "US"),
("Ontario", "CA-ON", "CA"),
("Québec", "CA-QC", "CA"),
("Nova Scotia", "CA-NS", "CA"),
("New Brunswick", "CA-NB", "CA"),
("Manitoba", "CA-MB", "CA"),
("British Columbia", "CA-BC", "CA"),
("Prince Edward Island", "CA-PE", "CA"),
("Saskatchewan", "CA-SK", "CA"),
("Alberta", "CA-AB", "CA"),
("Newfoundland and Labrador", "CA-NL", "CA"),
("Northwest Territories", "CA-NT", "CA"),
("Yukon", "CA-YT", "CA"),
("Nunavut", "CA-NU", "CA"),
("Aguascalientes", "MX-AGU", "MX"),
("Baja California", "MX-BCN", "MX"),
("Baja California Sur", "MX-BCS", "MX"),
("Campeche", "MX-CAM", "MX"),
("Chiapas", "MX-CHP", "MX"),
("Chihuahua", "MX-CHH", "MX"),
("Coahuila de Zaragoza", "MX-COA", "MX"),
("Colima", "MX-COL", "MX"),
("Durango", "MX-DUR", "MX"),
("Guanajuato", "MX-GUA", "MX"),
("Guerrero", "MX-GRO", "MX"),
("Hidalgo", "MX-HID", "MX"),
("Jalisco", "MX-JAL", "MX"),
("México State", "MX-MEX", "MX"),
("Michoacán de Ocampo", "MX-MIC", "MX"),
("Morelos", "MX-MOR", "MX"),
("Nayarit", "MX-NAY", "MX"),
("Nuevo León", "MX-NLE", "MX"),
("Oaxaca", "MX-OAX", "MX"),
("Puebla", "MX-PUE", "MX"),
("Querétaro", "MX-QUE", "MX"),
("Quintana Roo", "MX-ROO", "MX"),
("San Luis Potosí", "MX-SLP", "MX"),
("Sinaloa", "MX-SIN", "MX"),
("Sonora", "MX-SON", "MX"),
("Tabasco", "MX-TAB", "MX"),
("Tamaulipas", "MX-TAM", "MX"),
("Tlaxcala", "MX-TLA", "MX"),
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
("Yucatán", "MX-YUC", "MX"),
("Zacatecas", "MX-ZAC", "MX"),
("Ciudad de México", "MX-CMX", "MX"),
];
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
[
("US-CA", "Los Angeles"),
("US-CA", "San Diego"),
("US-CA", "San Francisco"),
("US-CA", "Sacramento"),
("US-TX", "Houston"),
("US-TX", "Dallas"),
("US-TX", "Austin"),
("US-TX", "San Antonio"),
("US-FL", "Miami"),
("US-FL", "Orlando"),
("US-FL", "Tampa"),
("US-NY", "New York"),
("US-NY", "Buffalo"),
("US-NY", "Rochester"),
("US-IL", "Chicago"),
("US-IL", "Springfield"),
("US-PA", "Philadelphia"),
("US-PA", "Pittsburgh"),
("US-AZ", "Phoenix"),
("US-AZ", "Tucson"),
("US-CO", "Denver"),
("US-CO", "Colorado Springs"),
("US-MA", "Boston"),
("US-MA", "Worcester"),
("US-WA", "Seattle"),
("US-WA", "Spokane"),
("US-GA", "Atlanta"),
("US-GA", "Savannah"),
("US-NV", "Las Vegas"),
("US-NV", "Reno"),
("US-MI", "Detroit"),
("US-MI", "Grand Rapids"),
("US-MN", "Minneapolis"),
("US-MN", "Saint Paul"),
("US-OH", "Columbus"),
("US-OH", "Cleveland"),
("US-OR", "Portland"),
("US-OR", "Salem"),
("US-TN", "Nashville"),
("US-TN", "Memphis"),
("US-VA", "Richmond"),
("US-VA", "Virginia Beach"),
("US-MD", "Baltimore"),
("US-MD", "Frederick"),
("US-DC", "Washington"),
("US-UT", "Salt Lake City"),
("US-UT", "Provo"),
("US-LA", "New Orleans"),
("US-LA", "Baton Rouge"),
("US-KY", "Louisville"),
("US-KY", "Lexington"),
("US-IA", "Des Moines"),
("US-IA", "Cedar Rapids"),
("US-OK", "Oklahoma City"),
("US-OK", "Tulsa"),
("US-NE", "Omaha"),
("US-NE", "Lincoln"),
("US-MO", "Kansas City"),
("US-MO", "St. Louis"),
("US-NC", "Charlotte"),
("US-NC", "Raleigh"),
("US-SC", "Columbia"),
("US-SC", "Charleston"),
("US-WI", "Milwaukee"),
("US-WI", "Madison"),
("US-MN", "Duluth"),
("US-AK", "Anchorage"),
("US-HI", "Honolulu"),
("CA-ON", "Toronto"),
("CA-ON", "Ottawa"),
("CA-QC", "Montréal"),
("CA-QC", "Québec City"),
("CA-BC", "Vancouver"),
("CA-BC", "Victoria"),
("CA-AB", "Calgary"),
("CA-AB", "Edmonton"),
("CA-MB", "Winnipeg"),
("CA-NS", "Halifax"),
("CA-SK", "Saskatoon"),
("CA-SK", "Regina"),
("CA-NB", "Moncton"),
("CA-NB", "Saint John"),
("CA-PE", "Charlottetown"),
("CA-NL", "St. John's"),
("CA-ON", "Hamilton"),
("CA-ON", "London"),
("CA-QC", "Gatineau"),
("CA-QC", "Laval"),
("CA-BC", "Kelowna"),
("CA-AB", "Red Deer"),
("CA-MB", "Brandon"),
("MX-CMX", "Ciudad de México"),
("MX-JAL", "Guadalajara"),
("MX-NLE", "Monterrey"),
("MX-PUE", "Puebla"),
("MX-ROO", "Cancún"),
("MX-GUA", "Guanajuato"),
("MX-MIC", "Morelia"),
("MX-BCN", "Tijuana"),
("MX-JAL", "Zapopan"),
("MX-NLE", "San Nicolás"),
("MX-CAM", "Campeche"),
("MX-TAB", "Villahermosa"),
("MX-VER", "Veracruz"),
("MX-OAX", "Oaxaca"),
("MX-SLP", "San Luis Potosí"),
("MX-CHH", "Chihuahua"),
("MX-AGU", "Aguascalientes"),
("MX-MEX", "Toluca"),
("MX-COA", "Saltillo"),
("MX-BCS", "La Paz"),
("MX-NAY", "Tepic"),
("MX-ZAC", "Zacatecas"),
];
public async Task SeedAsync(SqlConnection connection)
{
foreach (var (countryName, countryCode) in Countries)
{
await CreateCountryAsync(connection, countryName, countryCode);
}
foreach (
var (stateProvinceName, stateProvinceCode, countryCode) in States
)
{
await CreateStateProvinceAsync(
connection,
stateProvinceName,
stateProvinceCode,
countryCode
);
}
foreach (var (stateProvinceCode, cityName) in Cities)
{
await CreateCityAsync(connection, cityName, stateProvinceCode);
}
} }
private static async Task CreateCountryAsync( foreach (
SqlConnection connection, var (stateProvinceName, stateProvinceCode, countryCode) in States
string countryName,
string countryCode
) )
{ {
await using var command = new SqlCommand( await CreateStateProvinceAsync(
"dbo.USP_CreateCountry", connection,
connection stateProvinceName,
stateProvinceCode,
countryCode
); );
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CountryName", countryName);
command.Parameters.AddWithValue("@ISO3616_1", countryCode);
await command.ExecuteNonQueryAsync();
} }
private static async Task CreateStateProvinceAsync( foreach (var (stateProvinceCode, cityName) in Cities)
SqlConnection connection,
string stateProvinceName,
string stateProvinceCode,
string countryCode
)
{ {
await using var command = new SqlCommand( await CreateCityAsync(connection, cityName, stateProvinceCode);
"dbo.USP_CreateStateProvince",
connection
);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue(
"@StateProvinceName",
stateProvinceName
);
command.Parameters.AddWithValue("@ISO3616_2", stateProvinceCode);
command.Parameters.AddWithValue("@CountryCode", countryCode);
await command.ExecuteNonQueryAsync();
}
private static async Task CreateCityAsync(
SqlConnection connection,
string cityName,
string stateProvinceCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateCity",
connection
);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CityName", cityName);
command.Parameters.AddWithValue(
"@StateProvinceCode",
stateProvinceCode
);
await command.ExecuteNonQueryAsync();
} }
} }
private static async Task CreateCountryAsync(
SqlConnection connection,
string countryName,
string countryCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateCountry",
connection
);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CountryName", countryName);
command.Parameters.AddWithValue("@ISO3616_1", countryCode);
await command.ExecuteNonQueryAsync();
}
private static async Task CreateStateProvinceAsync(
SqlConnection connection,
string stateProvinceName,
string stateProvinceCode,
string countryCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateStateProvince",
connection
);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue(
"@StateProvinceName",
stateProvinceName
);
command.Parameters.AddWithValue("@ISO3616_2", stateProvinceCode);
command.Parameters.AddWithValue("@CountryCode", countryCode);
await command.ExecuteNonQueryAsync();
}
private static async Task CreateCityAsync(
SqlConnection connection,
string cityName,
string stateProvinceCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateCity",
connection
);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CityName", cityName);
command.Parameters.AddWithValue(
"@StateProvinceCode",
stateProvinceCode
);
await command.ExecuteNonQueryAsync();
}
} }

View File

@@ -1,24 +1,24 @@
using DBSeed; using Microsoft.Data.SqlClient;
using Microsoft.Data.SqlClient;
using DbUp; using DbUp;
using System.Reflection; using System.Reflection;
using Database.Seed;
string BuildConnectionString() string BuildConnectionString()
{ {
var server = Environment.GetEnvironmentVariable("DB_SERVER") var server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); ?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = Environment.GetEnvironmentVariable("DB_NAME") var dbName = Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); ?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER") var user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set"); ?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD") var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True"; ?? "True";
var builder = new SqlConnectionStringBuilder var builder = new SqlConnectionStringBuilder
{ {
@@ -33,6 +33,7 @@ string BuildConnectionString()
return builder.ConnectionString; return builder.ConnectionString;
} }
try try
{ {
var connectionString = BuildConnectionString(); var connectionString = BuildConnectionString();
@@ -72,7 +73,6 @@ try
using (connection) using (connection)
{ {
ISeeder[] seeders = ISeeder[] seeders =
[ [
new LocationSeeder(), new LocationSeeder(),

View File

@@ -1,273 +1,267 @@
using System.Data; using System.Data;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Domain.Core.Entities;
using Repository.Core.Repositories;
using idunno.Password; using idunno.Password;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace DBSeed namespace Database.Seed;
internal class UserSeeder : ISeeder
{ {
internal class UserSeeder : ISeeder private static readonly IReadOnlyList<(
string FirstName,
string LastName
)> SeedNames =
[
("Aarya", "Mathews"),
("Aiden", "Wells"),
("Aleena", "Gonzalez"),
("Alessandra", "Nelson"),
("Amari", "Tucker"),
("Ameer", "Huff"),
("Amirah", "Hicks"),
("Analia", "Dominguez"),
("Anne", "Jenkins"),
("Apollo", "Davis"),
("Arianna", "White"),
("Aubree", "Moore"),
("Aubrielle", "Raymond"),
("Aydin", "Odom"),
("Bowen", "Casey"),
("Brock", "Huber"),
("Caiden", "Strong"),
("Cecilia", "Rosales"),
("Celeste", "Barber"),
("Chance", "Small"),
("Clara", "Roberts"),
("Collins", "Brandt"),
("Damir", "Wallace"),
("Declan", "Crawford"),
("Dennis", "Decker"),
("Dylan", "Lang"),
("Eliza", "Kane"),
("Elle", "Poole"),
("Elliott", "Miles"),
("Emelia", "Lucas"),
("Emilia", "Simpson"),
("Emmett", "Lugo"),
("Ethan", "Stephens"),
("Etta", "Woods"),
("Gael", "Moran"),
("Grant", "Benson"),
("Gwen", "James"),
("Huxley", "Chen"),
("Isabella", "Fisher"),
("Ivan", "Mathis"),
("Jamir", "McMillan"),
("Jaxson", "Shields"),
("Jimmy", "Richmond"),
("Josiah", "Flores"),
("Kaden", "Enriquez"),
("Kai", "Lawson"),
("Karsyn", "Adkins"),
("Karsyn", "Proctor"),
("Kayden", "Henson"),
("Kaylie", "Spears"),
("Kinslee", "Jones"),
("Kora", "Guerra"),
("Lane", "Skinner"),
("Laylani", "Christian"),
("Ledger", "Carroll"),
("Leilany", "Small"),
("Leland", "McCall"),
("Leonard", "Calhoun"),
("Levi", "Ochoa"),
("Lillie", "Vang"),
("Lola", "Sheppard"),
("Luciana", "Poole"),
("Maddox", "Hughes"),
("Mara", "Blackwell"),
("Marcellus", "Bartlett"),
("Margo", "Koch"),
("Maurice", "Gibson"),
("Maxton", "Dodson"),
("Mia", "Parrish"),
("Millie", "Fuentes"),
("Nellie", "Villanueva"),
("Nicolas", "Mata"),
("Nicolas", "Miller"),
("Oakleigh", "Foster"),
("Octavia", "Pierce"),
("Paisley", "Allison"),
("Quincy", "Andersen"),
("Quincy", "Frazier"),
("Raiden", "Roberts"),
("Raquel", "Lara"),
("Rudy", "McIntosh"),
("Salvador", "Stein"),
("Samantha", "Dickson"),
("Solomon", "Richards"),
("Sylvia", "Hanna"),
("Talia", "Trujillo"),
("Thalia", "Farrell"),
("Trent", "Mayo"),
("Trinity", "Cummings"),
("Ty", "Perry"),
("Tyler", "Romero"),
("Valeria", "Pierce"),
("Vance", "Neal"),
("Whitney", "Bell"),
("Wilder", "Graves"),
("William", "Logan"),
("Zara", "Wilkinson"),
("Zaria", "Gibson"),
("Zion", "Watkins"),
("Zoie", "Armstrong"),
];
public async Task SeedAsync(SqlConnection connection)
{ {
private static readonly IReadOnlyList<( var generator = new PasswordGenerator();
string FirstName, var rng = new Random();
string LastName int createdUsers = 0;
)> SeedNames = int createdCredentials = 0;
[ int createdVerifications = 0;
("Aarya", "Mathews"),
("Aiden", "Wells"),
("Aleena", "Gonzalez"),
("Alessandra", "Nelson"),
("Amari", "Tucker"),
("Ameer", "Huff"),
("Amirah", "Hicks"),
("Analia", "Dominguez"),
("Anne", "Jenkins"),
("Apollo", "Davis"),
("Arianna", "White"),
("Aubree", "Moore"),
("Aubrielle", "Raymond"),
("Aydin", "Odom"),
("Bowen", "Casey"),
("Brock", "Huber"),
("Caiden", "Strong"),
("Cecilia", "Rosales"),
("Celeste", "Barber"),
("Chance", "Small"),
("Clara", "Roberts"),
("Collins", "Brandt"),
("Damir", "Wallace"),
("Declan", "Crawford"),
("Dennis", "Decker"),
("Dylan", "Lang"),
("Eliza", "Kane"),
("Elle", "Poole"),
("Elliott", "Miles"),
("Emelia", "Lucas"),
("Emilia", "Simpson"),
("Emmett", "Lugo"),
("Ethan", "Stephens"),
("Etta", "Woods"),
("Gael", "Moran"),
("Grant", "Benson"),
("Gwen", "James"),
("Huxley", "Chen"),
("Isabella", "Fisher"),
("Ivan", "Mathis"),
("Jamir", "McMillan"),
("Jaxson", "Shields"),
("Jimmy", "Richmond"),
("Josiah", "Flores"),
("Kaden", "Enriquez"),
("Kai", "Lawson"),
("Karsyn", "Adkins"),
("Karsyn", "Proctor"),
("Kayden", "Henson"),
("Kaylie", "Spears"),
("Kinslee", "Jones"),
("Kora", "Guerra"),
("Lane", "Skinner"),
("Laylani", "Christian"),
("Ledger", "Carroll"),
("Leilany", "Small"),
("Leland", "McCall"),
("Leonard", "Calhoun"),
("Levi", "Ochoa"),
("Lillie", "Vang"),
("Lola", "Sheppard"),
("Luciana", "Poole"),
("Maddox", "Hughes"),
("Mara", "Blackwell"),
("Marcellus", "Bartlett"),
("Margo", "Koch"),
("Maurice", "Gibson"),
("Maxton", "Dodson"),
("Mia", "Parrish"),
("Millie", "Fuentes"),
("Nellie", "Villanueva"),
("Nicolas", "Mata"),
("Nicolas", "Miller"),
("Oakleigh", "Foster"),
("Octavia", "Pierce"),
("Paisley", "Allison"),
("Quincy", "Andersen"),
("Quincy", "Frazier"),
("Raiden", "Roberts"),
("Raquel", "Lara"),
("Rudy", "McIntosh"),
("Salvador", "Stein"),
("Samantha", "Dickson"),
("Solomon", "Richards"),
("Sylvia", "Hanna"),
("Talia", "Trujillo"),
("Thalia", "Farrell"),
("Trent", "Mayo"),
("Trinity", "Cummings"),
("Ty", "Perry"),
("Tyler", "Romero"),
("Valeria", "Pierce"),
("Vance", "Neal"),
("Whitney", "Bell"),
("Wilder", "Graves"),
("William", "Logan"),
("Zara", "Wilkinson"),
("Zaria", "Gibson"),
("Zion", "Watkins"),
("Zoie", "Armstrong"),
];
public async Task SeedAsync(SqlConnection connection)
{ {
var generator = new PasswordGenerator(); const string firstName = "Test";
var rng = new Random(); const string lastName = "User";
int createdUsers = 0; const string email = "test.user@thebiergarten.app";
int createdCredentials = 0; var dob = new DateTime(1985, 03, 01);
int createdVerifications = 0; var hash = GeneratePasswordHash("password");
{ await RegisterUserAsync(
const string firstName = "Test"; connection,
const string lastName = "User"; $"{firstName}.{lastName}",
const string email = "test.user@thebiergarten.app"; firstName,
var dob = new DateTime(1985, 03, 01); lastName,
var hash = GeneratePasswordHash("password"); dob,
email,
await RegisterUserAsync( hash
connection,
$"{firstName}.{lastName}",
firstName,
lastName,
dob,
email,
hash
);
}
foreach (var (firstName, lastName) in SeedNames)
{
// prepare user fields
var username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng);
// generate a password and hash it
string pwd = generator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
);
string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential)
var id = await RegisterUserAsync(
connection,
username,
firstName,
lastName,
dob,
email,
hash
);
createdUsers++;
createdCredentials++;
// add user verification
if (await HasUserVerificationAsync(connection, id)) continue;
await AddUserVerificationAsync(connection, id);
createdVerifications++;
}
Console.WriteLine($"Created {createdUsers} user accounts.");
Console.WriteLine($"Added {createdCredentials} user credentials.");
Console.WriteLine($"Added {createdVerifications} user verifications.");
}
private static async Task<Guid> RegisterUserAsync(
SqlConnection connection,
string username,
string firstName,
string lastName,
DateTime dateOfBirth,
string email,
string hash
)
{
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
command.Parameters.Add("@DateOfBirth", SqlDbType.DateTime).Value = dateOfBirth;
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync();
return (Guid)result!;
}
private static string GeneratePasswordHash(string pwd)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = 65536,
Iterations = 4,
};
byte[] hash = argon2.GetBytes(32);
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
private static async Task<bool> HasUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
)
{
const string sql = """
SELECT 1
FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId;
""";
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync();
return result is not null;
}
private static async Task AddUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateUserVerification",
connection
); );
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
await command.ExecuteNonQueryAsync();
} }
foreach (var (firstName, lastName) in SeedNames)
private static DateTime GenerateDateOfBirth(Random random)
{ {
int age = 19 + random.Next(0, 30); // prepare user fields
DateTime baseDate = DateTime.UtcNow.Date.AddYears(-age); var username = $"{firstName[0]}.{lastName}";
int offsetDays = random.Next(0, 365); var email = $"{firstName}.{lastName}@thebiergarten.app";
return baseDate.AddDays(-offsetDays); var dob = GenerateDateOfBirth(rng);
// generate a password and hash it
string pwd = generator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
);
string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential)
var id = await RegisterUserAsync(
connection,
username,
firstName,
lastName,
dob,
email,
hash
);
createdUsers++;
createdCredentials++;
// add user verification
if (await HasUserVerificationAsync(connection, id)) continue;
await AddUserVerificationAsync(connection, id);
createdVerifications++;
} }
Console.WriteLine($"Created {createdUsers} user accounts.");
Console.WriteLine($"Added {createdCredentials} user credentials.");
Console.WriteLine($"Added {createdVerifications} user verifications.");
}
private static async Task<Guid> RegisterUserAsync(
SqlConnection connection,
string username,
string firstName,
string lastName,
DateTime dateOfBirth,
string email,
string hash
)
{
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
command.Parameters.Add("@DateOfBirth", SqlDbType.DateTime).Value = dateOfBirth;
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync();
return (Guid)result!;
}
private static string GeneratePasswordHash(string pwd)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = 65536,
Iterations = 4,
};
byte[] hash = argon2.GetBytes(32);
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
private static async Task<bool> HasUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
)
{
const string sql = """
SELECT 1
FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId;
""";
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync();
return result is not null;
}
private static async Task AddUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateUserVerification",
connection
);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
await command.ExecuteNonQueryAsync();
}
private static DateTime GenerateDateOfBirth(Random random)
{
int age = 19 + random.Next(0, 30);
DateTime baseDate = DateTime.UtcNow.Date.AddYears(-age);
int offsetDays = random.Next(0, 365);
return baseDate.AddDays(-offsetDays);
} }
} }

View File

@@ -1,4 +1,4 @@
namespace Domain.Core.Entities; namespace Domain.Entities;
public class UserAccount public class UserAccount
{ {

View File

@@ -1,4 +1,4 @@
namespace Domain.Core.Entities; namespace Domain.Entities;
public class UserCredential public class UserCredential
{ {

View File

@@ -1,4 +1,4 @@
namespace Domain.Core.Entities; namespace Domain.Entities;
public class UserVerification public class UserVerification
{ {

View File

@@ -1,4 +1,4 @@
namespace Service.Core.Jwt; namespace Infrastructure.Jwt;
public interface IJwtService public interface IJwtService
{ {

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Jwt</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.IdentityModel.JsonWebTokens"
Version="8.2.1"
/>
<PackageReference
Include="System.IdentityModel.Tokens.Jwt"
Version="8.2.1"
/>
</ItemGroup>
</Project>

View File

@@ -4,22 +4,28 @@ using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace Service.Core.Jwt; namespace Infrastructure.Jwt;
public class JwtService : IJwtService public class JwtService : IJwtService
{ {
private readonly string? _secret = Environment.GetEnvironmentVariable("JWT_SECRET"); private readonly string? _secret = Environment.GetEnvironmentVariable(
"JWT_SECRET"
);
public string GenerateJwt(Guid userId, string username, DateTime expiry) public string GenerateJwt(Guid userId, string username, DateTime expiry)
{ {
var handler = new JsonWebTokenHandler(); var handler = new JsonWebTokenHandler();
var key = Encoding.UTF8.GetBytes(_secret ?? throw new InvalidOperationException("secret not set")); var key = Encoding.UTF8.GetBytes(
_secret ?? throw new InvalidOperationException("secret not set")
);
// Base claims (always present) // Base claims (always present)
var claims = new List<Claim> var claims = new List<Claim>
{ {
new(JwtRegisteredClaimNames.Sub, userId.ToString()), new(JwtRegisteredClaimNames.Sub, userId.ToString()),
new(JwtRegisteredClaimNames.UniqueName, username), new(JwtRegisteredClaimNames.UniqueName, username),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
var tokenDescriptor = new SecurityTokenDescriptor var tokenDescriptor = new SecurityTokenDescriptor
@@ -28,7 +34,8 @@ public class JwtService : IJwtService
Expires = expiry, Expires = expiry,
SigningCredentials = new SigningCredentials( SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key), new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256) SecurityAlgorithms.HmacSha256
),
}; };
return handler.CreateToken(tokenDescriptor); return handler.CreateToken(tokenDescriptor);

View File

@@ -2,9 +2,9 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
namespace Service.Core.Password; namespace Infrastructure.PasswordHashing;
public class PasswordService : IPasswordService public class Argon2Infrastructure : IPasswordInfra
{ {
private const int SaltSize = 16; // 128-bit private const int SaltSize = 16; // 128-bit
private const int HashSize = 32; // 256-bit private const int HashSize = 32; // 256-bit
@@ -19,7 +19,7 @@ public class PasswordService : IPasswordService
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb, MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations Iterations = ArgonIterations,
}; };
var hash = argon2.GetBytes(HashSize); var hash = argon2.GetBytes(HashSize);
@@ -30,8 +30,12 @@ public class PasswordService : IPasswordService
{ {
try try
{ {
var parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries); var parts = stored.Split(
if (parts.Length != 2) return false; ':',
StringSplitOptions.RemoveEmptyEntries
);
if (parts.Length != 2)
return false;
var salt = Convert.FromBase64String(parts[0]); var salt = Convert.FromBase64String(parts[0]);
var expected = Convert.FromBase64String(parts[1]); var expected = Convert.FromBase64String(parts[1]);
@@ -41,7 +45,7 @@ public class PasswordService : IPasswordService
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
MemorySize = ArgonMemoryKb, MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations Iterations = ArgonIterations,
}; };
var actual = argon2.GetBytes(expected.Length); var actual = argon2.GetBytes(expected.Length);

View File

@@ -1,6 +1,6 @@
namespace Service.Core.Password; namespace Infrastructure.PasswordHashing;
public interface IPasswordService public interface IPasswordInfra
{ {
public string Hash(string password); public string Hash(string password);
public bool Verify(string password, string stored); public bool Verify(string password, string stored);

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.PasswordHashing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
</ItemGroup>
</Project>

View File

@@ -1,15 +1,15 @@
using Apps72.Dev.Data.DbMocker;
using Repository.Core.Repositories.Auth;
using FluentAssertions;
using Repository.Tests.Database;
using System.Data; using System.Data;
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Infrastructure.Repository.Auth;
using Repository.Tests.Database;
namespace Repository.Tests.Auth; namespace Repository.Tests.Auth;
public class AuthRepositoryTest public class AuthRepositoryTest
{ {
private static AuthRepository CreateRepo(MockDbConnection conn) private static AuthRepository CreateRepo(MockDbConnection conn) =>
=> new(new TestConnectionFactory(conn)); new(new TestConnectionFactory(conn));
[Fact] [Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount() public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
@@ -17,10 +17,12 @@ public class AuthRepositoryTest
var expectedUserId = Guid.NewGuid(); var expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
.When(cmd => cmd.CommandText == "USP_RegisterUser") .ReturnsTable(
.ReturnsTable(MockTable.WithColumns(("UserAccountId", typeof(Guid))) MockTable
.AddRow(expectedUserId)); .WithColumns(("UserAccountId", typeof(Guid)))
.AddRow(expectedUserId)
);
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync( var result = await repo.RegisterUserAsync(
@@ -47,29 +49,32 @@ public class AuthRepositoryTest
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail") .ReturnsTable(
.ReturnsTable(MockTable.WithColumns( MockTable
("UserAccountId", typeof(Guid)), .WithColumns(
("Username", typeof(string)), ("UserAccountId", typeof(Guid)),
("FirstName", typeof(string)), ("Username", typeof(string)),
("LastName", typeof(string)), ("FirstName", typeof(string)),
("Email", typeof(string)), ("LastName", typeof(string)),
("CreatedAt", typeof(DateTime)), ("Email", typeof(string)),
("UpdatedAt", typeof(DateTime?)), ("CreatedAt", typeof(DateTime)),
("DateOfBirth", typeof(DateTime)), ("UpdatedAt", typeof(DateTime?)),
("Timer", typeof(byte[])) ("DateOfBirth", typeof(DateTime)),
).AddRow( ("Timer", typeof(byte[]))
userId, )
"emailuser", .AddRow(
"Email", userId,
"User", "emailuser",
"emailuser@example.com", "Email",
DateTime.UtcNow, "User",
null, "emailuser@example.com",
new DateTime(1990, 5, 15), DateTime.UtcNow,
null null,
)); new DateTime(1990, 5, 15),
null
)
);
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com"); var result = await repo.GetUserByEmailAsync("emailuser@example.com");
@@ -87,8 +92,7 @@ public class AuthRepositoryTest
{ {
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
@@ -103,29 +107,34 @@ public class AuthRepositoryTest
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd =>
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") cmd.CommandText == "usp_GetUserAccountByUsername"
.ReturnsTable(MockTable.WithColumns( )
("UserAccountId", typeof(Guid)), .ReturnsTable(
("Username", typeof(string)), MockTable
("FirstName", typeof(string)), .WithColumns(
("LastName", typeof(string)), ("UserAccountId", typeof(Guid)),
("Email", typeof(string)), ("Username", typeof(string)),
("CreatedAt", typeof(DateTime)), ("FirstName", typeof(string)),
("UpdatedAt", typeof(DateTime?)), ("LastName", typeof(string)),
("DateOfBirth", typeof(DateTime)), ("Email", typeof(string)),
("Timer", typeof(byte[])) ("CreatedAt", typeof(DateTime)),
).AddRow( ("UpdatedAt", typeof(DateTime?)),
userId, ("DateOfBirth", typeof(DateTime)),
"usernameuser", ("Timer", typeof(byte[]))
"Username", )
"User", .AddRow(
"username@example.com", userId,
DateTime.UtcNow, "usernameuser",
null, "Username",
new DateTime(1985, 8, 20), "User",
null "username@example.com",
)); DateTime.UtcNow,
null,
new DateTime(1985, 8, 20),
null
)
);
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser"); var result = await repo.GetUserByUsernameAsync("usernameuser");
@@ -141,8 +150,9 @@ public class AuthRepositoryTest
{ {
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd =>
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername") cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
@@ -158,21 +168,26 @@ public class AuthRepositoryTest
var credentialId = Guid.NewGuid(); var credentialId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd =>
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId") cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
.ReturnsTable(MockTable.WithColumns( )
("UserCredentialId", typeof(Guid)), .ReturnsTable(
("UserAccountId", typeof(Guid)), MockTable
("Hash", typeof(string)), .WithColumns(
("CreatedAt", typeof(DateTime)), ("UserCredentialId", typeof(Guid)),
("Timer", typeof(byte[])) ("UserAccountId", typeof(Guid)),
).AddRow( ("Hash", typeof(string)),
credentialId, ("CreatedAt", typeof(DateTime)),
userId, ("Timer", typeof(byte[]))
"hashed_password_value", )
DateTime.UtcNow, .AddRow(
null credentialId,
)); userId,
"hashed_password_value",
DateTime.UtcNow,
null
)
);
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId); var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
@@ -189,8 +204,9 @@ public class AuthRepositoryTest
var userId = Guid.NewGuid(); var userId = Guid.NewGuid();
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd =>
.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId") cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
.ReturnsTable(MockTable.Empty()); .ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
@@ -206,14 +222,14 @@ public class AuthRepositoryTest
var newPasswordHash = "new_hashed_password"; var newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection(); var conn = new MockDbConnection();
conn.Mocks conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1); .ReturnsScalar(1);
var repo = CreateRepo(conn); var repo = CreateRepo(conn);
// Should not throw // Should not throw
var act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash); var act = async () =>
await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync(); await act.Should().NotThrowAsync();
} }
} }

View File

@@ -1,10 +1,11 @@
using System.Data.Common; using System.Data.Common;
using Repository.Core.Sql; using Infrastructure.Repository.Sql;
namespace Repository.Tests.Database; namespace Repository.Tests.Database;
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{ {
private readonly DbConnection _conn = conn; private readonly DbConnection _conn = conn;
public DbConnection CreateConnection() => _conn; public DbConnection CreateConnection() => _conn;
} }

View File

@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Domain/Domain.csproj", "Domain/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Repository.Tests.csproj"
COPY . .
WORKDIR "/src/Infrastructure/Infrastructure.Repository.Tests"
RUN dotnet build "./Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
RUN mkdir -p /app/test-results
WORKDIR /src/Infrastructure/Infrastructure.Repository.Tests
ENTRYPOINT ["dotnet", "test", "./Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"]

View File

@@ -4,7 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<RootNamespace>Repository.Tests</RootNamespace> <RootNamespace>Infrastructure.Repository.Tests</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -15,9 +15,18 @@
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.9.0" /> <PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" /> <PackageReference Include="DbMocker" Version="1.26.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" /> <PackageReference
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" /> Include="Microsoft.Extensions.Configuration"
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.0" /> Version="9.0.0"
/>
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="9.0.0"
/>
<PackageReference
Include="Microsoft.Extensions.Configuration.Binder"
Version="9.0.0"
/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
</ItemGroup> </ItemGroup>
@@ -26,6 +35,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Repository.Core\Repository.Core.csproj" /> <ProjectReference Include="..\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,180 @@
using Apps72.Dev.Data.DbMocker;
using FluentAssertions;
using Infrastructure.Repository.UserAccount;
using Repository.Tests.Database;
namespace Repository.Tests.UserAccount;
public class UserAccountRepositoryTest
{
private static UserAccountRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
[Fact]
public async Task GetByIdAsync_ReturnsRow_Mapped()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
"yerb",
"Aaron",
"Po",
"aaronpo@example.com",
new DateTime(2020, 1, 1),
null,
new DateTime(1990, 1, 1),
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(
Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
);
result.Should().NotBeNull();
result!.Username.Should().Be("yerb");
result.Email.Should().Be("aaronpo@example.com");
}
[Fact]
public async Task GetAllAsync_ReturnsMultipleRows()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"a",
"A",
"A",
"a@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
.AddRow(
Guid.NewGuid(),
"b",
"B",
"B",
"b@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList();
results.Should().HaveCount(2);
results
.Select(r => r.Username)
.Should()
.BeEquivalentTo(new[] { "a", "b" });
}
[Fact]
public async Task GetByUsername_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"lookupuser",
"L",
"U",
"lookup@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser");
result.Should().NotBeNull();
result!.Email.Should().Be("lookup@example.com");
}
[Fact]
public async Task GetByEmail_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
MockTable
.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
Guid.NewGuid(),
"byemail",
"B",
"E",
"byemail@example.com",
DateTime.UtcNow,
null,
DateTime.UtcNow.Date,
null
)
);
var repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com");
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}
}

View File

@@ -0,0 +1,195 @@
using System.Data;
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository.Auth;
public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Entities.UserAccount>(connectionFactory),
IAuthRepository
{
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
return new Domain.Entities.UserAccount
{
UserAccountId = userAccountId,
Username = username,
FirstName = firstName,
LastName = lastName,
Email = email,
DateOfBirth = dateOfBirth,
CreatedAt = DateTime.UtcNow,
};
}
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync()
? MapToCredentialEntity(reader)
: null;
}
public async Task RotateCredentialAsync(
Guid userAccountId,
string newPasswordHash
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
protected override Domain.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Entities.UserAccount
{
UserAccountId = reader.GetGuid(
reader.GetOrdinal("UserAccountId")
),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(
reader.GetOrdinal("DateOfBirth")
),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
};
}
/// <summary>
/// Maps a data reader row to a UserCredential entity.
/// </summary>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new UserCredential
{
UserCredentialId = reader.GetGuid(
reader.GetOrdinal("UserCredentialId")
),
UserAccountId = reader.GetGuid(
reader.GetOrdinal("UserAccountId")
),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
};
// Optional columns
var hasTimer =
reader
.GetSchemaTable()
?.Rows.Cast<System.Data.DataRow>()
.Any(r =>
string.Equals(
r["ColumnName"]?.ToString(),
"Timer",
StringComparison.OrdinalIgnoreCase
)
) ?? false;
if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"];
}
return entity;
}
/// <summary>
/// Helper method to add a parameter to a database command.
/// </summary>
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -0,0 +1,67 @@
using Domain.Entities;
namespace Infrastructure.Repository.Auth;
/// <summary>
/// Repository for authentication-related database operations including user registration and credential management.
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
string email
);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
string username
);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
Guid userAccountId
);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
}

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Repository.Core</RootNamespace> <RootNamespace>Infrastructure.Repository</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
@@ -12,7 +12,10 @@
Version="160.1000.6" Version="160.1000.6"
/> />
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" /> <PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="8.0.0"
/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain\Domain.csproj" />

View File

@@ -0,0 +1,17 @@
using System.Data.Common;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository;
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
return connection;
}
protected abstract T MapToEntity(DbDataReader reader);
}

View File

@@ -0,0 +1,49 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Repository.Sql;
public class DefaultSqlConnectionFactory(IConfiguration configuration)
: ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(
configuration
);
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
var fullConnectionString = Environment.GetEnvironmentVariable(
"DB_CONNECTION_STRING"
);
if (!string.IsNullOrEmpty(fullConnectionString))
{
return fullConnectionString;
}
// Try to build from individual environment variables (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
var connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
{
return connString;
}
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}

View File

@@ -0,0 +1,8 @@
using System.Data.Common;
namespace Infrastructure.Repository.Sql;
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
}

View File

@@ -0,0 +1,61 @@
using Microsoft.Data.SqlClient;
namespace Infrastructure.Repository.Sql;
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
public static string BuildConnectionString(string? databaseName = null)
{
var server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException(
"DB_SERVER environment variable is not set"
);
var dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException(
"DB_NAME environment variable is not set"
);
var user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException(
"DB_USER environment variable is not set"
);
var password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException(
"DB_PASSWORD environment variable is not set"
);
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = true,
};
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");
}
}

View File

@@ -0,0 +1,16 @@
namespace Infrastructure.Repository.UserAccount;
public interface IUserAccountRepository
{
Task<Domain.Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Domain.Entities.UserAccount>> GetAllAsync(
int? limit,
int? offset
);
Task UpdateAsync(Domain.Entities.UserAccount userAccount);
Task DeleteAsync(Guid id);
Task<Domain.Entities.UserAccount?> GetByUsernameAsync(
string username
);
Task<Domain.Entities.UserAccount?> GetByEmailAsync(string email);
}

View File

@@ -0,0 +1,149 @@
using System.Data;
using System.Data.Common;
using Infrastructure.Repository.Sql;
namespace Infrastructure.Repository.UserAccount;
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Entities.UserAccount>(connectionFactory),
IUserAccountRepository
{
public async Task<Domain.Entities.UserAccount?> GetByIdAsync(
Guid id
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<
IEnumerable<Domain.Entities.UserAccount>
> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Domain.Entities.UserAccount>();
while (await reader.ReadAsync())
{
users.Add(MapToEntity(reader));
}
return users;
}
public async Task UpdateAsync(
Domain.Entities.UserAccount userAccount
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await command.ExecuteNonQueryAsync();
}
public async Task<Domain.Entities.UserAccount?> GetByUsernameAsync(
string username
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Entities.UserAccount?> GetByEmailAsync(
string email
)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
protected override Domain.Entities.UserAccount MapToEntity(
DbDataReader reader
)
{
return new Domain.Entities.UserAccount
{
UserAccountId = reader.GetGuid(
reader.GetOrdinal("UserAccountId")
),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(
reader.GetOrdinal("DateOfBirth")
),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"],
};
}
private static void AddParameter(
DbCommand command,
string name,
object? value
)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

View File

@@ -1,168 +0,0 @@
using System.Data;
using System.Data.Common;
using Domain.Core.Entities;
using Repository.Core.Sql;
namespace Repository.Core.Repositories.Auth
{
public class AuthRepository : Repository<Domain.Core.Entities.UserAccount>, IAuthRepository
{
public AuthRepository(ISqlConnectionFactory connectionFactory)
: base(connectionFactory)
{
}
public async Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RegisterUser";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
AddParameter(command, "@FirstName", firstName);
AddParameter(command, "@LastName", lastName);
AddParameter(command, "@Email", email);
AddParameter(command, "@DateOfBirth", dateOfBirth);
AddParameter(command, "@Hash", passwordHash);
var result = await command.ExecuteScalarAsync();
var userAccountId = result != null ? (Guid)result : Guid.Empty;
return new Domain.Core.Entities.UserAccount
{
UserAccountId = userAccountId,
Username = username,
FirstName = firstName,
LastName = lastName,
Email = email,
DateOfBirth = dateOfBirth,
CreatedAt = DateTime.UtcNow
};
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(string email)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(string username)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
}
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
protected override Domain.Core.Entities.UserAccount MapToEntity(DbDataReader reader)
{
return new Domain.Core.Entities.UserAccount
{
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"]
};
}
/// <summary>
/// Maps a data reader row to a UserCredential entity.
/// </summary>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
var entity = new UserCredential
{
UserCredentialId = reader.GetGuid(reader.GetOrdinal("UserCredentialId")),
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Hash = reader.GetString(reader.GetOrdinal("Hash")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
};
// Optional columns
var hasTimer = reader.GetSchemaTable()?.Rows
.Cast<System.Data.DataRow>()
.Any(r => string.Equals(r["ColumnName"]?.ToString(), "Timer",
StringComparison.OrdinalIgnoreCase)) ??
false;
if (hasTimer)
{
entity.Timer = reader.IsDBNull(reader.GetOrdinal("Timer")) ? null : (byte[])reader["Timer"];
}
return entity;
}
/// <summary>
/// Helper method to add a parameter to a database command.
/// </summary>
private static void AddParameter(DbCommand command, string name, object? value)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}

View File

@@ -1,61 +0,0 @@
using Domain.Core.Entities;
namespace Repository.Core.Repositories.Auth
{
/// <summary>
/// Repository for authentication-related database operations including user registration and credential management.
/// </summary>
public interface IAuthRepository
{
/// <summary>
/// Registers a new user with account details and initial credential.
/// Uses stored procedure: USP_RegisterUser
/// </summary>
/// <param name="username">Unique username for the user</param>
/// <param name="firstName">User's first name</param>
/// <param name="lastName">User's last name</param>
/// <param name="email">User's email address</param>
/// <param name="dateOfBirth">User's date of birth</param>
/// <param name="passwordHash">Hashed password</param>
/// <returns>The newly created UserAccount with generated ID</returns>
Task<Domain.Core.Entities.UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash);
/// <summary>
/// Retrieves a user account by email address (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByEmail
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByEmailAsync(string email);
/// <summary>
/// Retrieves a user account by username (typically used for login).
/// Uses stored procedure: usp_GetUserAccountByUsername
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
Task<Domain.Core.Entities.UserAccount?> GetUserByUsernameAsync(string username);
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account.
/// Uses stored procedure: USP_GetActiveUserCredentialByUserAccountId
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one.
/// Uses stored procedure: USP_RotateUserCredential
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
}
}

View File

@@ -1,18 +0,0 @@
using System.Data.Common;
using Repository.Core.Sql;
namespace Repository.Core.Repositories
{
public abstract class Repository<T>(ISqlConnectionFactory connectionFactory)
where T : class
{
protected async Task<DbConnection> CreateConnection()
{
var connection = connectionFactory.CreateConnection();
await connection.OpenAsync();
return connection;
}
protected abstract T MapToEntity(DbDataReader reader);
}
}

View File

@@ -1,15 +0,0 @@
using Domain.Core.Entities;
namespace Repository.Core.Repositories.UserAccount
{
public interface IUserAccountRepository
{
Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(Guid id);
Task<IEnumerable<Domain.Core.Entities.UserAccount>> GetAllAsync(int? limit, int? offset);
Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount);
Task DeleteAsync(Guid id);
Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(string username);
Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(string email);
}
}

View File

@@ -1,130 +0,0 @@
using System.Data;
using System.Data.Common;
using Domain.Core.Entities;
using Repository.Core.Sql;
namespace Repository.Core.Repositories.UserAccount
{
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: Repository<Domain.Core.Entities.UserAccount>(connectionFactory), IUserAccountRepository
{
public async Task<Domain.Core.Entities.UserAccount?> GetByIdAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<IEnumerable<Domain.Core.Entities.UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetAllUserAccounts";
command.CommandType = CommandType.StoredProcedure;
if (limit.HasValue)
AddParameter(command, "@Limit", limit.Value);
if (offset.HasValue)
AddParameter(command, "@Offset", offset.Value);
await using var reader = await command.ExecuteReaderAsync();
var users = new List<Domain.Core.Entities.UserAccount>();
while (await reader.ReadAsync())
{
users.Add(MapToEntity(reader));
}
return users;
}
public async Task UpdateAsync(Domain.Core.Entities.UserAccount userAccount)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_UpdateUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccount.UserAccountId);
AddParameter(command, "@Username", userAccount.Username);
AddParameter(command, "@FirstName", userAccount.FirstName);
AddParameter(command, "@LastName", userAccount.LastName);
AddParameter(command, "@Email", userAccount.Email);
AddParameter(command, "@DateOfBirth", userAccount.DateOfBirth);
await command.ExecuteNonQueryAsync();
}
public async Task DeleteAsync(Guid id)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_DeleteUserAccount";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", id);
await command.ExecuteNonQueryAsync();
}
public async Task<Domain.Core.Entities.UserAccount?> GetByUsernameAsync(string username)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
public async Task<Domain.Core.Entities.UserAccount?> GetByEmailAsync(string email)
{
await using var connection = await CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using var reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
protected override Domain.Core.Entities.UserAccount MapToEntity(DbDataReader reader)
{
return new Domain.Core.Entities.UserAccount
{
UserAccountId = reader.GetGuid(reader.GetOrdinal("UserAccountId")),
Username = reader.GetString(reader.GetOrdinal("Username")),
FirstName = reader.GetString(reader.GetOrdinal("FirstName")),
LastName = reader.GetString(reader.GetOrdinal("LastName")),
Email = reader.GetString(reader.GetOrdinal("Email")),
CreatedAt = reader.GetDateTime(reader.GetOrdinal("CreatedAt")),
UpdatedAt = reader.IsDBNull(reader.GetOrdinal("UpdatedAt"))
? null
: reader.GetDateTime(reader.GetOrdinal("UpdatedAt")),
DateOfBirth = reader.GetDateTime(reader.GetOrdinal("DateOfBirth")),
Timer = reader.IsDBNull(reader.GetOrdinal("Timer"))
? null
: (byte[])reader["Timer"]
};
}
private static void AddParameter(DbCommand command, string name, object? value)
{
var p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}
}

View File

@@ -1,46 +0,0 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Repository.Core.Sql
{
public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlConnectionFactory
{
private readonly string _connectionString = GetConnectionString(configuration);
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
var fullConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
if (!string.IsNullOrEmpty(fullConnectionString))
{
return fullConnectionString;
}
// Try to build from individual environment variables (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
}
catch (InvalidOperationException)
{
// Fall back to configuration-based connection string if env vars are not set
var connString = configuration.GetConnectionString("Default");
if (!string.IsNullOrEmpty(connString))
{
return connString;
}
throw new InvalidOperationException(
"Database connection string not configured. Set DB_CONNECTION_STRING or DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD env vars or ConnectionStrings:Default."
);
}
}
public DbConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}
}

View File

@@ -1,9 +0,0 @@
using System.Data.Common;
namespace Repository.Core.Sql
{
public interface ISqlConnectionFactory
{
DbConnection CreateConnection();
}
}

View File

@@ -1,50 +0,0 @@
using Microsoft.Data.SqlClient;
namespace Repository.Core.Sql
{
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Expects DB_SERVER, DB_NAME, DB_USER, DB_PASSWORD, and DB_TRUST_SERVER_CERTIFICATE.
/// </summary>
/// <param name="databaseName">Optional override for the database name. If null, uses DB_NAME env var.</param>
/// <returns>A properly formatted SQL Server connection string.</returns>
public static string BuildConnectionString(string? databaseName = null)
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var builder = new SqlConnectionStringBuilder
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = true,
Encrypt = true
};
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// </summary>
/// <returns>A connection string for the master database.</returns>
public static string BuildMasterConnectionString()
{
return BuildConnectionString("master");
}
}
}

View File

@@ -1,14 +0,0 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Repository/Repository.Core/Repository.Core.csproj", "Repository/Repository.Core/"]
COPY ["Repository/Repository.Tests/Repository.Tests.csproj", "Repository/Repository.Tests/"]
RUN dotnet restore "Repository/Repository.Tests/Repository.Tests.csproj"
COPY . .
WORKDIR "/src/Repository/Repository.Tests"
RUN dotnet build "./Repository.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS final
RUN mkdir -p /app/test-results
WORKDIR /src/Repository/Repository.Tests
ENTRYPOINT ["dotnet", "test", "./Repository.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/repository-tests.trx"]

View File

@@ -1,119 +0,0 @@
using Apps72.Dev.Data.DbMocker;
using Repository.Core.Repositories.UserAccount;
using FluentAssertions;
using Repository.Tests.Database;
namespace Repository.Tests.UserAccount;
public class UserAccountRepositoryTest
{
private static UserAccountRepository CreateRepo(MockDbConnection conn)
=> new(new TestConnectionFactory(conn));
[Fact]
public async Task GetByIdAsync_ReturnsRow_Mapped()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
"yerb", "Aaron", "Po", "aaronpo@example.com",
new DateTime(2020, 1, 1), null,
new DateTime(1990, 1, 1), null));
var repo = CreateRepo(conn);
var result = await repo.GetByIdAsync(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
result.Should().NotBeNull();
result!.Username.Should().Be("yerb");
result.Email.Should().Be("aaronpo@example.com");
}
[Fact]
public async Task GetAllAsync_ReturnsMultipleRows()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetAllUserAccounts")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "a", "A", "A", "a@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
null)
.AddRow(Guid.NewGuid(), "b", "B", "B", "b@example.com", DateTime.UtcNow, null, DateTime.UtcNow.Date,
null));
var repo = CreateRepo(conn);
var results = (await repo.GetAllAsync(null, null)).ToList();
results.Should().HaveCount(2);
results.Select(r => r.Username).Should().BeEquivalentTo(new[] { "a", "b" });
}
[Fact]
public async Task GetByUsername_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "lookupuser", "L", "U", "lookup@example.com", DateTime.UtcNow, null,
DateTime.UtcNow.Date, null));
var repo = CreateRepo(conn);
var result = await repo.GetByUsernameAsync("lookupuser");
result.Should().NotBeNull();
result!.Email.Should().Be("lookup@example.com");
}
[Fact]
public async Task GetByEmail_ReturnsRow()
{
var conn = new MockDbConnection();
conn.Mocks
.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.WithColumns(
("UserAccountId", typeof(Guid)),
("Username", typeof(string)),
("FirstName", typeof(string)),
("LastName", typeof(string)),
("Email", typeof(string)),
("CreatedAt", typeof(DateTime)),
("UpdatedAt", typeof(DateTime?)),
("DateOfBirth", typeof(DateTime)),
("Timer", typeof(byte[]))
).AddRow(Guid.NewGuid(), "byemail", "B", "E", "byemail@example.com", DateTime.UtcNow, null,
DateTime.UtcNow.Date, null));
var repo = CreateRepo(conn);
var result = await repo.GetByEmailAsync("byemail@example.com");
result.Should().NotBeNull();
result!.Username.Should().Be("byemail");
}
}

View File

@@ -1,12 +1,12 @@
using Domain.Core.Entities; using Domain.Entities;
using Repository.Core.Repositories.Auth; using Infrastructure.PasswordHashing;
using Service.Core.Password; using Infrastructure.Repository.Auth;
namespace Service.Core.Auth; namespace Service.Core.Auth;
public class AuthService( public class AuthService(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordService passwordService IPasswordInfra passwordInfra
) : IAuthService ) : IAuthService
{ {
public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password) public async Task<UserAccount> RegisterAsync(UserAccount userAccount, string password)
@@ -19,7 +19,7 @@ public class AuthService(
} }
// password hashing // password hashing
var hashed = passwordService.Hash(password); var hashed = passwordInfra.Hash(password);
// Register user with hashed password // Register user with hashed password
return await authRepo.RegisterUserAsync( return await authRepo.RegisterUserAsync(
@@ -43,6 +43,6 @@ public class AuthService(
var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId); var activeCred = await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId);
if (activeCred is null) return null; if (activeCred is null) return null;
return !passwordService.Verify(password, activeCred.Hash) ? null : user; return !passwordInfra.Verify(password, activeCred.Hash) ? null : user;
} }
} }

View File

@@ -1,4 +1,4 @@
using Domain.Core.Entities; using Domain.Entities;
namespace Service.Core.Auth; namespace Service.Core.Auth;

View File

@@ -12,6 +12,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\Repository\Repository.Core\Repository.Core.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,4 @@
using Domain.Core.Entities; using Domain.Entities;
namespace Service.Core.User; namespace Service.Core.User;

View File

@@ -1,5 +1,5 @@
using Domain.Core.Entities; using Domain.Entities;
using Repository.Core.Repositories.UserAccount; using Infrastructure.Repository.UserAccount;
namespace Service.Core.User; namespace Service.Core.User;