32 Commits

Author SHA1 Message Date
Aaron Po
ac97c03612 commit csharpier config update 2026-06-20 15:26:41 -04:00
Aaron Po
460b0dca9a update mailkit 2026-06-20 15:26:05 -04:00
Aaron Po
f7e3ace9c1 Format ./web/backend/Shared/Shared.Contracts 2026-06-20 15:14:54 -04:00
Aaron Po
95f4fb8b91 Format ./web/backend/Shared/Shared.Application 2026-06-20 15:14:53 -04:00
Aaron Po
1ebe17bf42 Format ./web/backend/API/API.Specs 2026-06-20 15:14:21 -04:00
Aaron Po
678ee21bbd Format ./web/backend/API/API.Core 2026-06-20 15:14:20 -04:00
Aaron Po
3d046e369c Format ./web/backend/Domain/Domain.Exceptions 2026-06-20 15:13:58 -04:00
Aaron Po
034436d018 Format ./web/backend/Domain/Domain.Entities 2026-06-20 15:13:57 -04:00
Aaron Po
58833f330c Format ./web/backend/Database/Database.Seed 2026-06-20 15:13:32 -04:00
Aaron Po
bfda60d831 Format ./web/backend/Database/Database.Migrations 2026-06-20 15:13:31 -04:00
Aaron Po
39b5d5bf1d Format ./web/backend/Infrastructure/Infrastructure.Sql 2026-06-20 15:13:02 -04:00
Aaron Po
e5d08c9ec8 Format ./web/backend/Infrastructure/Infrastructure.PasswordHashing 2026-06-20 15:13:02 -04:00
Aaron Po
1272a7ff40 Format ./web/backend/Infrastructure/Infrastructure.Jwt 2026-06-20 15:13:02 -04:00
Aaron Po
0933ded022 Format ./web/backend/Infrastructure/Infrastructure.Email.Templates 2026-06-20 15:13:01 -04:00
Aaron Po
a37f2971a7 Format ./web/backend/Infrastructure/Infrastructure.Email 2026-06-20 15:13:01 -04:00
Aaron Po
9ac039dfeb Format ./web/backend/Features/Features.UserManagement.Tests 2026-06-20 15:09:43 -04:00
Aaron Po
1875dcc8bf Format ./web/backend/Features/Features.UserManagement 2026-06-20 15:09:43 -04:00
Aaron Po
c19af68c7c Format ./web/backend/Features/Features.Emails.Tests 2026-06-20 15:09:42 -04:00
Aaron Po
433c3fbe3d Format ./web/backend/Features/Features.Emails 2026-06-20 15:09:42 -04:00
Aaron Po
aadeab3afa Format ./web/backend/Features/Features.Breweries.Tests 2026-06-20 15:09:42 -04:00
Aaron Po
0f77ae43b5 Format ./web/backend/Features/Features.Breweries 2026-06-20 15:09:41 -04:00
Aaron Po
4de455f34d Format ./web/backend/Features/Features.Auth.Tests 2026-06-20 15:09:41 -04:00
Aaron Po
2bdd7bb0c0 Format ./web/backend/Features/Features.Auth 2026-06-20 15:09:40 -04:00
Aaron Po
5b882ac51c code style cleanup 2026-06-20 13:55:17 -04:00
Aaron Po
0c3b0e99e8 Update documentation to reflect new architecture and previous refactors 2026-06-20 02:13:54 -04:00
Aaron Po
a1a63b11bb Migrate Auth and Emails to vertical slices (Features.Auth, Features.Emails)
Auth and Emails land together since Auth's registration/resend flows need
Emails to exist first. Service.Auth's four services (Register, Login,
Confirmation, Token) collapse into MediatR commands/queries in
Features.Auth; TokenService stays as a slice-internal service since
multiple handlers call it. Auth no longer references Emails directly —
it sends SendRegistrationEmailCommand/SendResendConfirmationEmailCommand
(defined in Shared.Application) which Features.Emails handles, so neither
slice has a project reference on the other.

Service.Emails' IEmailService becomes Features.Emails' IEmailDispatcher,
simplified to take (firstName, email, token) instead of a full UserAccount
since that's all it ever used. API.Specs' TestApiFactory/MockEmailService
are updated to swap in the relocated interface.

Also deletes Infrastructure.Repository now that Breweries, UserManagement,
and Auth have all moved their repos out of it, and replaces the two
now-dead docker-compose test services (repository.tests, service.auth.tests,
both pointing at deleted Dockerfiles) with a single unit.tests service that
runs every Features.*.Tests project via Dockerfile.tests.
2026-06-20 01:49:12 -04:00
Aaron Po
6db004066f Migrate UserManagement to a vertical slice (Features.UserManagement)
Same pattern as the Breweries migration: Service.UserManagement and the
UserAccount repository fold into Features.UserManagement, with MediatR
queries replacing UserService. UpdateUserCommand/Handler carry forward
IUserService.UpdateAsync as-is (it has no HTTP route today, and adding one
is a separate decision from this migration). Adds handler/repository test
coverage that didn't exist before, since Service.UserManagement had no
test project.
2026-06-20 01:49:12 -04:00
Aaron Po
a8c1b17095 Migrate Breweries to a vertical slice (Features.Breweries)
Moves brewery CRUD from the Service.Breweries/Infrastructure.Repository
layered split into a single Features.Breweries project: MediatR
commands/queries replace BreweryService, and the repository moves in
alongside its own controller. Extracts Infrastructure.Sql out of
Infrastructure.Repository (just the generic ADO.NET connection/base-repo
plumbing) since Breweries no longer needs the rest of that project, while
Auth and UserAccount repos still do until their own migration.

Also fixes the API.Core and Infrastructure.Repository.Tests Dockerfiles,
which were restoring against COPY destinations that didn't match the
projects' actual relative paths (silently working only because of the
COPY . . that followed); both now restore against Core.slnx with correctly
nested paths, verified with a real docker build.
2026-06-20 01:49:12 -04:00
Aaron Po
62d682472e Scaffold vertical-slice migration: Shared.Contracts, Shared.Application, MediatR
Begins the move to vertical-slice architecture. ResponseBody moves out of
API.Core into Shared.Contracts so slices won't have to depend on the API
host project for it. Shared.Application adds the MediatR pipeline's
ValidationBehavior plus the cross-slice email commands that Features.Auth
will send and Features.Emails will handle, keeping slices decoupled from
each other.
2026-06-20 01:49:12 -04:00
Aaron Po
e07b46fcce add tests 2026-06-20 01:49:12 -04:00
Aaron Po
d896f04274 finish brewery feature set 2026-06-20 01:49:12 -04:00
Aaron Po
254b2afb9f add xmldoc comments 2026-06-20 01:49:12 -04:00
231 changed files with 15710 additions and 13463 deletions

View File

@@ -41,7 +41,7 @@ Data generation pipeline (C++):
Active areas in the repository:
- .NET 10 backend (layered architecture) + SQL Server
- .NET 10 backend (vertical-slice architecture with MediatR) + SQL Server
- React 19 website (React Router 7 + Vite)
- Shared Biergarten theme system + Storybook coverage
- Auth flows and account/email integration (local Mailpit in dev compose)
@@ -105,7 +105,8 @@ npm run test:storybook:playwright
```text
web/
backend/ .NET API + domain/service/infrastructure + DB projects
backend/ .NET API: API.Core (host), Features.* (vertical slices),
Shared.*, Domain.*, Infrastructure.*, Database.* projects
frontend/ React Router website + Storybook + Playwright/Vitest
tooling/

View File

@@ -7,61 +7,65 @@ This document describes the active architecture of The Biergarten App.
The Biergarten App is a monorepo with a clear split between the backend and the
active website:
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture
- **Frontend**: React 19 + React Router 7 website in `src/Website`
- **Architecture Style**: Layered backend plus server-rendered React frontend
- **Backend**: .NET 10 Web API with SQL Server, organized as vertical feature
slices with MediatR
- **Frontend**: React 19 + React Router 7 website in `web/frontend`
- **Architecture Style**: Vertical-slice backend plus server-rendered React
frontend
The legacy Next.js frontend has been retained in `src/Website-v1` for reference
only and is documented in
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
for reference only.
## Diagrams
For visual representations, see:
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture
- [architecture.svg](website/diagrams-out/architecture.svg) - Vertical-slice
architecture diagram
- [deployment.svg](website/diagrams-out/deployment.svg) - Docker deployment
diagram
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) -
- [authentication-flow.svg](website/diagrams-out/authentication-flow.svg) -
Authentication workflow
- [database-schema.svg](diagrams-out/database-schema.svg) - Database
- [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
relationships
## Backend Architecture
### Layered Architecture Pattern
### Vertical Slice Architecture Pattern
The backend follows a strict layered architecture:
The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`,
`Features.UserManagement`, `Features.Emails`) is a single project that owns
its own controller, MediatR commands/queries/handlers, validators, and
repository, end to end:
```
┌─────────────────────────────────────┐
API Layer (Controllers)
│ - HTTP Endpoints
│ - Request/Response mapping
│ - Swagger/OpenAPI │
└─────────────────────────────────────┘
┌───────────────────────────────────────────────────────────────────────
API.Core (thin host)
│ - Program.cs wiring: MediatR + AddApplicationPart per slice
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter
└───────────────────────────────────────────────────────────────────────┘
↓ discovers controllers via AddApplicationPart
┌───────────────┬───────────────┬───────────────────┬───────────────────┐
│ Features.Auth │Features. │ Features. │ Features.Emails │
│ │Breweries │ UserManagement │ (no controller, │
│ Controller │ Controller │ Controller │ internal only) │
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
│ Queries + │ Queries + │ Queries + │ Handlers │
│ Handlers │ Handlers │ Handlers │ │
│ Repository │ Repository │ Repository │ EmailDispatcher │
└───────────────┴───────────────┴───────────────────┴───────────────────┘
↓ each slice depends only on shared/domain/infra, never on another slice
┌─────────────────────────┬─────────────────────────┬────────────────────┐
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
│ (ResponseBody envelope) │ (ValidationBehavior, │ Domain.Exceptions │
│ │ cross-slice email cmds) │ │
└─────────────────────────┴─────────────────────────┴────────────────────┘
┌─────────────────────────────────────┐
Service Layer (Business Logic)
- Authentication logic
│ - User management │
│ - Validation & orchestration │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Infrastructure Layer (Tools) │
│ - JWT token generation │
│ - Password hashing (Argon2id) │
│ - Email services │
│ - Repository implementations │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Domain Layer (Entities) │
│ - UserAccount, UserCredential │
│ - Pure POCO classes │
│ - No external dependencies │
└─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────
Infrastructure.Sql, Infrastructure.Jwt, Infrastructure.PasswordHashing,
Infrastructure.Email, Infrastructure.Email.Templates
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Database (SQL Server) │
@@ -70,75 +74,123 @@ The backend follows a strict layered architecture:
└─────────────────────────────────────┘
```
Slices never reference each other's project. The one cross-slice
interaction (`Features.Auth` triggering a confirmation email handled by
`Features.Emails`) goes through a MediatR command (`SendRegistrationEmailCommand`)
whose contract lives in `Shared.Application`, so neither slice takes a
project reference on the other.
### Layer Responsibilities
#### API Layer (`API.Core`)
**Purpose**: HTTP interface and request handling
**Purpose**: Thin ASP.NET Core host: no business logic, no controllers of
its own
**Components**:
- Controllers (`AuthController`, `UserController`)
- Middleware for error handling
- Swagger/OpenAPI documentation
- Health check endpoints
- `Program.cs`: registers MediatR (scanning every `Features.*` assembly),
FluentValidation, and uses `AddApplicationPart` so each slice's controllers
are discovered by MVC
- `GlobalException.cs`: global exception filter (host-level cross-cutting
concern)
- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware
- Swagger/OpenAPI documentation, health check endpoints
**Dependencies**:
- Service layer
- ASP.NET Core framework
- Every `Features.*` project (for controller/MediatR discovery)
- `Shared.Contracts`, `Shared.Application`
- `Infrastructure.Jwt` (for the auth middleware)
**Rules**:
- No business logic
- Only request/response transformation
- Delegates all work to Service layer
- No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices
#### Service Layer (`Service.Auth`, `Service.UserManagement`)
#### Feature Slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`)
**Purpose**: Business logic and orchestration
**Purpose**: Each slice is the complete vertical for one business capability
**Components** (per slice):
- `Controllers/`: HTTP endpoints, binding directly to Command/Query types
as the request contract
- `Commands/<Operation>/` and `Queries/<Operation>/`: one folder per
operation, each containing the Command/Query record, its
`IRequestHandler`, and (for commands) a FluentValidation validator
- `Repository/`: the slice's own stored-procedure repository
implementation
- `Dtos/`: response shapes returned by query handlers (never the raw
domain entity)
- `DependencyInjection/`: an `AddFeaturesX()` extension method registering
the slice's repository/services
`Features.Emails` has no `Controllers/` folder. It's invoked only via
MediatR commands sent from other slices, never over HTTP.
**Dependencies**:
- `Domain.Entities`, `Domain.Exceptions`
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever
infrastructure project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails)
- `Shared.Contracts`, `Shared.Application`
- **Never** another `Features.*` project
**Rules**:
- All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through
MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly
#### Shared Projects (`Shared.Contracts`, `Shared.Application`)
**Purpose**: The minimum cross-slice surface area required because every
slice needs it, or because duplicating it four times would be worse than
sharing it
**Components**:
- Authentication services (login, registration)
- User management services
- Business rule validation
- Transaction coordination
**Dependencies**:
- Infrastructure layer (repositories, JWT, password hashing)
- Domain entities
- `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
envelope every controller returns
- `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
MediatR pipeline behavior that runs FluentValidation before a handler
executes) and the cross-slice email commands
(`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
**Rules**:
- Contains all business logic
- Coordinates multiple infrastructure components
- No direct database access (uses repositories)
- Returns domain models, not DTOs
- Kept deliberately small: this is the exception to "no slice depends on
another slice," not a general-purpose dumping ground
#### Infrastructure Layer
**Purpose**: Technical capabilities and external integrations
**Purpose**: Technical capabilities and external integrations, shared by
whichever slices need them
**Components**:
- **Infrastructure.Repository**: Data access via stored procedures
- **Infrastructure.Sql**: generic ADO.NET connection/command plumbing
(`ISqlConnectionFactory`, the abstract `Repository<T>` base class), not
domain-specific; each slice's own `Repository/` folder builds on this
- **Infrastructure.Jwt**: JWT token generation and validation
- **Infrastructure.PasswordHashing**: Argon2id password hashing
- **Infrastructure.Email**: Email sending capabilities
- **Infrastructure.Email.Templates**: Email template rendering
- **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
- **Infrastructure.Email.Templates**: Email template rendering (Razor
components)
**Dependencies**:
- Domain entities
- External libraries (ADO.NET, JWT, Argon2, etc.)
- External libraries (ADO.NET, JWT, Argon2, MailKit, etc.)
**Rules**:
- Implements technical concerns
- No business logic
- Reusable across services
- Implements technical concerns only, no business logic
- Reusable across slices
#### Domain Layer (`Domain.Entities`)
@@ -163,21 +215,55 @@ The backend follows a strict layered architecture:
### Design Patterns
#### Vertical Slice + MediatR
**Purpose**: Organize code by feature instead of by technical layer, so
everything needed to understand or change one capability lives in one
project
**Implementation**:
- Each HTTP write operation is a `Command` (e.g. `CreateBreweryCommand` in
`Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (e.g. `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes
- Query handlers map to a dedicated response `Dto`, so domain entities never
leak over the wire
**Example**:
```csharp
public record CreateBreweryCommand(Guid PostedById, string BreweryName, string Description, ...)
: IRequest<BreweryDto>;
public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandler<CreateBreweryCommand, BreweryDto>
{
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken) { ... }
}
```
#### Repository Pattern
**Purpose**: Abstract database access behind interfaces
**Implementation**:
**Implementation**: each slice owns its own repository, scoped to that
feature only:
- `IAuthRepository` - Authentication queries
- `IUserAccountRepository` - User account queries
- `DefaultSqlConnectionFactory` - Connection management
- `Features.Auth/Repository/IAuthRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
connection factory every slice's repository builds on
**Benefits**:
- Testable (easy to mock)
- SQL-first approach (stored procedures)
- Centralized data access logic
- Each slice's data access logic is self-contained
**Example**:
@@ -193,17 +279,19 @@ public interface IAuthRepository
**Purpose**: Loose coupling and testability
**Configuration**: `Program.cs` registers all services
**Configuration**: `Program.cs` wires up MediatR/FluentValidation across
every `Features.*` assembly; each slice exposes its own `AddFeaturesX()`
extension method that registers its repository and slice-internal services
**Lifetimes**:
- Scoped: Repositories, Services (per request)
- Singleton: Connection factories, JWT configuration
- Scoped: Repositories, slice-internal services (per request)
- Singleton: `ISqlConnectionFactory`
- Transient: Utilities, helpers
#### SQL-First Approach
**Purpose**: Leverage database capabilities
**Purpose**: Push complex logic into the database
**Strategy**:
@@ -220,13 +308,13 @@ public interface IAuthRepository
## Frontend Architecture
### Active Website (`src/Website`)
### Active Website (`web/frontend`)
The current website is a React Router 7 application with server-side rendering
enabled.
```text
src/Website/
web/frontend/
├── app/
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
│ ├── lib/ Auth helpers, schemas, and theme metadata
@@ -262,10 +350,9 @@ All component styling should prefer semantic tokens such as `primary`,
### Legacy Frontend
The previous Next.js frontend has been archived at `src/Website-v1`. Active
product and engineering documentation should point to `src/Website`, while
legacy notes live in
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
The previous Next.js frontend has been archived at `archive/next-js-web-app/`
for reference only. Active product and engineering documentation should point
to `web/frontend`.
## Security Architecture
@@ -390,7 +477,7 @@ scripts/
- Testing (`docker-compose.test.yaml`)
- Production (`docker-compose.prod.yaml`)
For details, see [Docker Guide](docker.md).
For details, see [Docker Guide](website/docker.md).
### Health Checks
@@ -416,11 +503,10 @@ healthcheck:
│ Integration │ ← API.Specs (Reqnroll)
│ Tests │
├──────────────┤
│ Unit Tests │ ← Service.Auth.Tests
(Service)Repository.Tests
├──────────────┤
Unit Tests │
│ (Repository) │
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
(per slice,Features.UserManagement.Tests, Features.Emails.Tests
│ handlers + │ (commands/queries/handlers + that slice's own
repository) │ repository, mocked with Moq/DbMocker)
└──────────────┘
```
@@ -431,4 +517,4 @@ healthcheck:
- Mock external dependencies
- Test database for integration tests
For details, see [Testing Guide](testing.md).
For details, see [Testing Guide](website/testing.md).

File diff suppressed because one or more lines are too long

View File

@@ -4,41 +4,67 @@ skinparam backgroundColor #FFFFFF
skinparam defaultFontName Arial
skinparam packageStyle rectangle
title The Biergarten App - Layered Architecture
title The Biergarten App - Vertical Slice Architecture
package "API Layer" #E3F2FD {
[API.Core\nASP.NET Core Web API] as API
package "API.Core (thin host)" #E3F2FD {
[API.Core\nASP.NET Core Web API host] as API
note right of API
- Controllers (Auth, User)
- Swagger/OpenAPI
- Middleware
- Health Checks
- Program.cs wiring only
- MediatR + AddApplicationPart
per Features.* assembly
- Swagger/OpenAPI, health checks
- JWT auth middleware
- Global exception filter
end note
}
package "Service Layer" #F3E5F5 {
[Service.Auth] as AuthSvc
[Service.UserManagement] as UserSvc
note right of AuthSvc
- Business Logic
- Validation
- Orchestration
package "Feature Slices" #F3E5F5 {
[Features.Auth] as AuthSlice
[Features.Breweries] as BrewerySlice
[Features.UserManagement] as UserSlice
[Features.Emails] as EmailsSlice
note right of AuthSlice
Each slice owns its own:
- Controller (HTTP endpoints)
- Commands/Queries + Handlers
- Validators
- Repository
end note
note right of EmailsSlice
No controller - invoked only
via MediatR commands sent
from other slices
end note
}
package "Shared" #FCE4EC {
[Shared.Contracts] as SharedContracts
[Shared.Application] as SharedApp
note right of SharedApp
- ValidationBehavior
(MediatR pipeline)
- Cross-slice email commands
(SendRegistrationEmailCommand,
SendResendConfirmationEmailCommand)
end note
}
package "Infrastructure Layer" #FFF3E0 {
[Infrastructure.Repository] as Repo
[Infrastructure.Sql] as Sql
[Infrastructure.Jwt] as JWT
[Infrastructure.PasswordHashing] as PwdHash
[Infrastructure.Email] as Email
[Infrastructure.Email.Templates] as EmailTemplates
}
package "Domain Layer" #E8F5E9 {
[Domain.Entities] as Domain
[Domain.Exceptions] as DomainExceptions
note right of Domain
- UserAccount
- UserCredential
- UserVerification
- BreweryPost
end note
}
@@ -48,28 +74,53 @@ database "SQL Server" {
}
' Relationships
API --> AuthSvc
API --> UserSvc
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
API ..> EmailsSlice : MediatR scan only\n(no controller)
AuthSvc --> Repo
AuthSvc --> JWT
AuthSvc --> PwdHash
AuthSvc --> Email
AuthSlice --> Sql
AuthSlice --> JWT
AuthSlice --> PwdHash
AuthSlice --> SharedContracts
AuthSlice --> SharedApp
UserSvc --> Repo
BrewerySlice --> Sql
BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp
Repo --> SP
Repo --> Domain
UserSlice --> Sql
UserSlice --> SharedContracts
UserSlice --> SharedApp
EmailsSlice --> Email
EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp
' The one cross-slice interaction: a MediatR command contract, not a project reference
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> SP
Sql --> Domain
SP --> Tables
AuthSvc --> Domain
UserSvc --> Domain
AuthSlice --> Domain
BrewerySlice --> Domain
UserSlice --> Domain
AuthSlice --> DomainExceptions
' Notes
note left of Repo
note left of Sql
SQL-first approach
All queries via
stored procedures
end note
note bottom of AuthSlice
No Features.* project
ever references another
Features.* project directly
end note
@enduml

View File

@@ -112,38 +112,32 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
end note
}
node "Infrastructure.Repository.Tests\n(Unit Tests)" as RepoTests {
component "xUnit + DbMocker" as RepoComp
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
component "xUnit + Moq + DbMocker" as UnitComp
note right
Tests:
- AuthRepository
- UserAccountRepository
- SQL command building
Builds Dockerfile.tests, which
restores/builds against Core.slnx
then runs `dotnet test` for every
Features/*.Tests project in turn:
- Features.Auth.Tests
- Features.Breweries.Tests
- Features.UserManagement.Tests
- Features.Emails.Tests
Uses: Mock connections
Uses: Moq for handlers,
DbMocker for repositories
No real database needed
end note
}
node "Service.Auth.Tests\n(Unit Tests)" as SvcTests {
component "xUnit + Moq" as SvcComp
note right
Tests:
- RegisterService
- LoginService
- Token generation
Uses: Mocked dependencies
No database or infrastructure
end note
}
}
folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1
file "repository-tests/\n results.trx" as Result2
file "service-auth-tests/\n results.trx" as Result3
file "Features.Auth.Tests.trx" as Result2
file "Features.Breweries.Tests.trx" as Result3
file "Features.UserManagement.Tests.trx" as Result4
file "Features.Emails.Tests.trx" as Result5
note bottom
TRX format
@@ -170,17 +164,16 @@ DevAPI .up.> DevSeed : depends_on
TestMig --> TestDB : 1. Migrate
TestSeed --> TestDB : 2. Seed
Specs --> TestDB : 3. Integration test
RepoTests ..> TestDB : Mock (no connection)
SvcTests ..> TestDB : Mock (no connection)
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq/DbMocker,\nno real connection)
TestMig .up.> TestDB : depends_on
TestSeed .up.> TestMig : depends_on
Specs .up.> TestSeed : depends_on
UnitTests .up.> TestSeed : depends_on
' Test results export
Specs --> Results : Export TRX
RepoTests --> Results : Export TRX
SvcTests --> Results : Export TRX
UnitTests --> Results : Export TRX (one .trx per slice)
' Network notes
note bottom of DevDB

View File

@@ -13,7 +13,7 @@ The project uses Docker Compose to orchestrate multiple services:
- .NET API
- Test runners
See the [deployment diagram](diagrams/pdf/deployment.pdf) for visual
See the [deployment diagram](diagrams-out/deployment.svg) for visual
representation.
## Docker Compose Environments
@@ -25,9 +25,10 @@ representation.
**Features**:
- Persistent SQL Server volume
- Hot reload support
- NuGet package cache volume (speeds up rebuilds)
- Swagger UI enabled
- Seed data included
- Local Mailpit SMTP server for dev email testing
- `CLEAR_DATABASE=true` (drops and recreates schema)
**Services**:
@@ -37,28 +38,30 @@ sqlserver # SQL Server 2022 (port 1433)
database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
mailpit # Local dev SMTP server + UI (port 8025)
```
**Start Development Environment**:
```bash
docker compose -f docker-compose.dev.yaml up -d
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
**Access**:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
- SQL Server: localhost:1433 (sa credentials from .env.dev)
**Stop Environment**:
```bash
# Stop services (keep volumes)
docker compose -f docker-compose.dev.yaml down
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
# Stop and remove volumes (fresh start)
docker compose -f docker-compose.dev.yaml down -v
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
```
### 2. Testing (`docker-compose.test.yaml`)
@@ -80,24 +83,22 @@ sqlserver # Test database
database.migrations # Fresh schema
database.seed # Test data
api.specs # Reqnroll BDD tests
repository.tests # Repository unit tests
service.auth.tests # Service unit tests
unit.tests # All Features.*.Tests unit test projects
```
**Run Tests**:
```bash
# Run all tests
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
# View results
ls -la test-results/
cat test-results/api-specs/results.trx
cat test-results/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
cat test-results/Features.Auth.Tests.trx
# Clean up
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
### 3. Production (`docker-compose.prod.yaml`)
@@ -124,7 +125,7 @@ api.core # Production API
**Deploy Production**:
```bash
docker compose -f docker-compose.prod.yaml up -d
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
```
## Service Dependencies
@@ -194,13 +195,6 @@ volumes:
Test results are written to host filesystem for CI/CD integration.
**Code Volumes** (development only):
```yaml
volumes:
- ./src:/app/src # Hot reload for development
```
## Networks
Each environment uses isolated bridge networks:
@@ -223,7 +217,9 @@ environment:
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
JWT_SECRET: "${JWT_SECRET}"
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
```
For complete list, see [Environment Variables](environment-variables.md).
@@ -234,7 +230,7 @@ For complete list, see [Environment Variables](environment-variables.md).
```bash
# Running services
docker compose -f docker-compose.dev.yaml ps
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps
# All containers (including stopped)
docker ps -a
@@ -244,13 +240,13 @@ docker ps -a
```bash
# All services
docker compose -f docker-compose.dev.yaml logs -f
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
# Specific service
docker compose -f docker-compose.dev.yaml logs -f api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f api.core
# Last 100 lines
docker compose -f docker-compose.dev.yaml logs --tail=100 api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core
```
### Execute Commands in Container
@@ -267,39 +263,39 @@ docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -
```bash
# Restart all services
docker compose -f docker-compose.dev.yaml restart
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart
# Restart specific service
docker compose -f docker-compose.dev.yaml restart api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart api.core
# Rebuild and restart
docker compose -f docker-compose.dev.yaml up -d --build api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core
```
### Build Images
```bash
# Build all images
docker compose -f docker-compose.dev.yaml build
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build
# Build specific service
docker compose -f docker-compose.dev.yaml build api.core
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.core
# Build without cache
docker compose -f docker-compose.dev.yaml build --no-cache
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache
```
### Clean Up
```bash
# Stop and remove containers
docker compose -f docker-compose.dev.yaml down
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
# Remove containers and volumes
docker compose -f docker-compose.dev.yaml down -v
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
# Remove containers, volumes, and images
docker compose -f docker-compose.dev.yaml down -v --rmi all
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --rmi all
# System-wide cleanup
docker system prune -af --volumes

View File

@@ -17,7 +17,7 @@ The application uses environment variables for:
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (`src/Website`)
### Frontend (`web/frontend`)
The active website reads runtime values from the server environment for its auth
and API integration.
@@ -130,7 +130,7 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
```
## Frontend Variables (`src/Website`)
## Frontend Variables (`web/frontend`)
The active website does not use the old Next.js/Prisma environment model. Its
core runtime variables are:
@@ -147,7 +147,7 @@ NODE_ENV=development # Standard Node runtime mode
- **Required**: Yes for local development
- **Default in code**: `http://localhost:8080`
- **Used by**: `src/Website/app/lib/auth.server.ts`
- **Used by**: `web/frontend/app/lib/auth.server.ts`
- **Purpose**: Routes website auth actions to the .NET API
#### `SESSION_SECRET`
@@ -163,15 +163,27 @@ NODE_ENV=development # Standard Node runtime mode
- **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode
### Admin Account (Seeding)
### SMTP Configuration (Backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation
and account emails.
```bash
ADMIN_PASSWORD=SecureAdminPassword123! # Initial admin password for seeding
SMTP_HOST=mailpit # Required, no default
SMTP_PORT=1025 # Optional, defaults to 587
SMTP_USERNAME=<username> # Optional, no default
SMTP_PASSWORD=<password> # Optional, no default
SMTP_USE_SSL=false # Optional, defaults to true
SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
```
- **Required**: No (only needed for seeding)
- **Purpose**: Sets admin account password during database seeding
- **Security**: Use strong password, change immediately in production
- **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025)
- **Production**: point at a real provider (SendGrid, Mailgun, Amazon SES,
etc.)
## Docker-Specific Variables
@@ -191,34 +203,33 @@ MSSQL_PID=Express # SQL Server edition (Express, Developer, Ente
## Environment File Structure
### Backend/Docker (Root Directory)
### Backend/Docker (`web/` Directory)
```
.env.example # Template (tracked in Git)
.env.dev # Development config (gitignored)
.env.test # Testing config (gitignored)
.env.prod # Production config (gitignored)
web/.env.example # Template (tracked in Git)
web/.env.dev # Development config (gitignored)
web/.env.test # Testing config (gitignored)
web/.env.prod # Production config (gitignored)
```
**Setup**:
```bash
cp .env.example .env.dev
# Edit .env.dev with your values
cp web/.env.example web/.env.dev
# Edit web/.env.dev with your values
```
## Legacy Frontend Variables
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed
from this active reference. See
[archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you need the
legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
been removed from this active reference, since that app is retained for
reference only and is not run as part of the active stack.
**Docker Compose Mapping**:
- `docker-compose.dev.yaml``.env.dev`
- `docker-compose.test.yaml``.env.test`
- `docker-compose.prod.yaml``.env.prod`
- `web/docker-compose.dev.yaml``web/.env.dev`
- `web/docker-compose.test.yaml``web/.env.test`
- `web/docker-compose.prod.yaml``web/.env.prod`
## Variable Reference Table
@@ -234,6 +245,13 @@ legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
| `SMTP_HOST` | ✓ | | ✓ | Yes | SMTP server host |
| `SMTP_PORT` | ✓ | | ✓ | No | Defaults to `587` |
| `SMTP_USERNAME` | ✓ | | ✓ | No | SMTP auth username |
| `SMTP_PASSWORD` | ✓ | | ✓ | No | SMTP auth password |
| `SMTP_USE_SSL` | ✓ | | ✓ | No | Defaults to `true` |
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode |
@@ -254,9 +272,12 @@ legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
Variables are validated at startup:
- Missing required variables cause application to fail
- JWT_SECRET length is enforced (min 32 chars)
- Connection string format is validated
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
values) cause the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the
"minimum 32 characters" guidance above is a recommendation, not an
enforced check
### Frontend Validation
@@ -284,6 +305,13 @@ REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# SMTP (Mailpit in dev)
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
# Migration
CLEAR_DATABASE=true

View File

@@ -1,7 +1,7 @@
# Getting Started
This guide covers local setup for the current Biergarten stack: the .NET backend
in `src/Core` and the active React Router frontend in `src/Website`.
in `web/backend` and the active React Router frontend in `web/frontend`.
## Prerequisites
@@ -22,7 +22,7 @@ cd the-biergarten-app
### 2. Configure Backend Environment Variables
```bash
cp .env.example .env.dev
cp web/.env.example web/.env.dev
```
At minimum, ensure `.env.dev` includes valid database and token values:
@@ -43,20 +43,21 @@ See [Environment Variables](environment-variables.md) for the full list.
### 3. Start the Backend Stack
```bash
docker compose -f docker-compose.dev.yaml up -d
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
This starts SQL Server, migrations, seeding, and the API.
This starts SQL Server, migrations, seeding, the API, and Mailpit (local dev SMTP).
Available endpoints:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
### 4. Start the Active Frontend
```bash
cd src/Website
cd web/frontend
npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
```
@@ -71,7 +72,7 @@ Required frontend runtime variables for local work:
### 5. Optional: Run Storybook
```bash
cd src/Website
cd web/frontend
npm run storybook
```
@@ -82,15 +83,15 @@ Storybook runs at http://localhost:6006 by default.
### Backend
```bash
docker compose -f docker-compose.dev.yaml logs -f
docker compose -f docker-compose.dev.yaml down
docker compose -f docker-compose.dev.yaml down -v
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v
```
### Frontend
```bash
cd src/Website
cd web/frontend
npm run lint
npm run typecheck
npm run format:check
@@ -115,7 +116,7 @@ export WEBSITE_BASE_URL="http://localhost:3000"
### 2. Run Migrations and Seed
```bash
cd src/Core
cd web/backend
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
dotnet run --project Database/Database.Seed/Database.Seed.csproj
```
@@ -128,12 +129,11 @@ dotnet run --project API/API.Core/API.Core.csproj
## Legacy Frontend Note
The previous Next.js frontend now lives in `src/Website-v1` and is not the
active website. Legacy setup details have been moved to
[docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
only and is not the active website.
## Next Steps
- Review [Architecture](architecture.md)
- Review [Architecture](../architecture.md)
- Run backend and frontend checks from [Testing](testing.md)
- Use [Docker Guide](docker.md) for container troubleshooting

View File

@@ -7,9 +7,13 @@ Biergarten App.
The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
- **Service.Auth.Tests** - Unit tests for authentication business logic
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against
a live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`), covering that
slice's command/query handlers and its own repository (mocked with Moq /
DbMocker, no real database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared
website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
@@ -21,7 +25,7 @@ The easiest way to run all tests is using Docker Compose, which sets up an
isolated test environment:
```bash
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
```
This command:
@@ -41,15 +45,17 @@ ls -la test-results/
# View specific test results
cat test-results/api-specs/results.trx
cat test-results/repository-tests/results.trx
cat test-results/service-auth-tests/results.trx
cat test-results/Features.Auth.Tests.trx
cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx
```
### Clean Up
```bash
# Remove test containers and volumes
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
## Running Tests Locally
@@ -59,7 +65,7 @@ You can run individual test projects locally without Docker:
### Integration Tests (API.Specs)
```bash
cd src/Core
cd web/backend
dotnet test API/API.Specs/API.Specs.csproj
```
@@ -69,32 +75,31 @@ dotnet test API/API.Specs/API.Specs.csproj
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
### Repository Tests
### Feature Slice Unit Tests
Each feature slice has its own test project, covering its command/query
handlers and repository:
```bash
cd src/Core
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
cd web/backend
dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
# Or run all of them at once via the solution:
for proj in Features/*.Tests; do dotnet test "$proj"; done
```
**Requirements**:
- SQL Server instance running (uses mock data)
### Service Tests
```bash
cd src/Core
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
```
**Requirements**:
- No database required (uses Moq for mocking)
- No database required (handlers use Moq; repository tests use DbMocker to
simulate SQL Server responses)
### Frontend Storybook Tests
```bash
cd src/Website
cd web/frontend
npm install
npm run test:storybook
```
@@ -108,7 +113,7 @@ npm run test:storybook
### Frontend Playwright Storybook Tests
```bash
cd src/Website
cd web/frontend
npm install
npm run test:storybook:playwright
```
@@ -124,27 +129,28 @@ npm run test:storybook:playwright
### Current Coverage
**Authentication & User Management**:
**Features.Auth.Tests**:
- User registration with validation
- User login with JWT token generation
- Password hashing and verification (Argon2id)
- JWT token generation and claims
- Invalid credentials handling
- 404 error responses
- Email confirmation and confirmation-email resend
- Refresh token exchange
- JWT token generation, validation, and claims handling
- `IAuthRepository` data access (DbMocker-backed)
- Invalid credentials and 404 error responses (via API.Specs)
**Repository Layer**:
**Features.Breweries.Tests**:
- User account creation
- User credential management
- GetUserByUsername queries
- Stored procedure execution
- Brewery create/update/delete commands and get-by-id/get-all queries
**Service Layer**:
**Features.UserManagement.Tests**:
- Login service with password verification
- Register service with validation
- Business logic for authentication flow
- User get-by-id/get-all queries and the (currently unrouted) update command
**Features.Emails.Tests**:
- Registration and resend-confirmation email dispatch handlers
**Frontend UI Coverage**:
@@ -156,10 +162,7 @@ npm run test:storybook:playwright
### Planned Coverage
- [ ] Email verification workflow
- [ ] Password reset functionality
- [ ] Token refresh mechanism
- [ ] Brewery data management
- [ ] Beer post operations
- [ ] User follow/unfollow
- [ ] Image upload service
@@ -204,21 +207,28 @@ npm run test:storybook:playwright
```
API.Specs/
├── Features/
│ ├── Authentication.feature # Login/register scenarios
── UserManagement.feature # User CRUD scenarios
│ ├── Registration.feature # Registration scenarios
── Login.feature # Login scenarios
│ ├── Confirmation.feature # Email confirmation scenarios
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios
│ ├── TokenRefresh.feature # Refresh token scenarios
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
│ └── NotFound.feature # 404 handling
├── Steps/
│ ├── AuthenticationSteps.cs # Step definitions
│ └── UserManagementSteps.cs
── Mocks/
── TestApiFactory.cs # Test server setup
│ ├── AuthSteps.cs # Step definitions for the Auth features
│ └── ApiGeneralSteps.cs # Shared/general step definitions
── Mocks/
── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
```
**Example Feature**:
```gherkin
Feature: User Authentication
Feature: User Registration
As a user
I want to register and login
I want to register
So that I can access the platform
Scenario: Successful user registration
@@ -228,45 +238,51 @@ Scenario: Successful user registration
And my account should be created
```
### Infrastructure.Repository.Tests
### Features.Auth.Tests
```
Infrastructure.Repository.Tests/
├── AuthRepositoryTests.cs # Auth repository tests
├── UserAccountRepositoryTests.cs # User account tests
└── TestFixtures/
── DatabaseFixture.cs # Shared test setup
Features.Auth.Tests/
├── Commands/
│ ├── RegisterUserHandlerTests.cs
│ ├── ConfirmUserHandlerTests.cs
── ResendConfirmationEmailHandlerTests.cs
│ └── RefreshTokenHandlerTests.cs
├── Queries/
│ └── LoginHandlerTests.cs
├── Repository/
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
└── Services/
├── TokenServiceRefreshTests.cs
└── TokenServiceValidationTests.cs
```
### Service.Auth.Tests
```
Service.Auth.Tests/
├── LoginService.test.cs # Login business logic tests
└── RegisterService.test.cs # Registration business logic tests
```
Each of the other three slices (`Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`) follows the same
shape: a `Commands/`/`Queries/` folder with one test file per handler.
## Writing Tests
### Unit Test Example (xUnit)
```csharp
public class LoginServiceTests
public class LoginHandlerTests
{
[Fact]
public async Task LoginAsync_ValidCredentials_ReturnsToken()
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{
// Arrange
var mockRepo = new Mock<IAuthRepository>();
var mockJwt = new Mock<IJwtService>();
var service = new AuthService(mockRepo.Object, mockJwt.Object);
var authRepoMock = new Mock<IAuthRepository>();
var passwordInfraMock = new Mock<IPasswordInfrastructure>();
var tokenServiceMock = new Mock<ITokenService>();
var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
// ...set up mocks for a known user/credential...
// Act
var result = await service.LoginAsync("testuser", "password123");
var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Token.Should().NotBeNullOrEmpty();
result.AccessToken.Should().NotBeNullOrEmpty();
}
}
```
@@ -288,9 +304,9 @@ configuration:
```bash
# CI/CD command
docker compose -f docker-compose.test.yaml build
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
Exit codes:
@@ -302,7 +318,7 @@ Frontend UI checks should also be included in CI for the active website
workspace:
```bash
cd src/Website
cd web/frontend
npm ci
npm run test:storybook
npm run test:storybook:playwright
@@ -315,7 +331,7 @@ npm run test:storybook:playwright
Ensure SQL Server is running and environment variables are set:
```bash
docker compose -f docker-compose.test.yaml ps
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml ps
```
### Port Conflicts
@@ -328,13 +344,13 @@ If port 1433 is in use, stop other SQL Server instances or modify the port in
Clean up test database:
```bash
docker compose -f docker-compose.test.yaml down -v
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
### View Container Logs
```bash
docker compose -f docker-compose.test.yaml logs <service-name>
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml logs <service-name>
```
## Best Practices

View File

@@ -2,8 +2,7 @@
## Overview
The Core project implements comprehensive JWT token validation across three
token types:
The Core project validates JWTs across three token types:
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
@@ -15,7 +14,7 @@ token types:
### Infrastructure Layer
#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs)
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
Low-level JWT operations.
@@ -25,77 +24,92 @@ Low-level JWT operations.
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:**
[JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs)
[JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness
### Service Layer
### Features.Auth Slice
#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs)
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
High-level token validation with context (token type, user extraction).
Both token generation and validation live on the same slice-internal
service (there is no separate validation service/class).
**Methods:**
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
**Returns:** `ValidatedToken` record containing:
- `UserId` (Guid)
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:**
[TokenValidationService.cs](Service.Auth/TokenValidationService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
#### [ITokenService](Service.Auth/ITokenService.cs)
Token generation (existing service extended).
**Methods:**
**Generation methods:**
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation
token
**Validation methods:**
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
**Returns (validation):** `ValidatedToken` record containing:
- `UserId` (Guid)
- `Username` (string)
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:** [TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Auth`'s own
`AddFeaturesAuth()` extension method, except for the lower-level
`ITokenInfrastructure` (JWT signing/verification) it depends on, which is
registered by the host (`API.Core/Program.cs`) since
`JwtAuthenticationHandler` (host-level auth middleware) also depends on
it directly.
### Integration Points
#### [ConfirmationService](Service.Auth/IConfirmationService.cs)
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
**Flow:**
1. Receives confirmation token from user
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()`
1. Receives confirmation token from user via `ConfirmUserCommand`
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token
4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database
4. Calls `IAuthRepository.ConfirmUserAccountAsync()` to update database
5. Returns confirmation result
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs)
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
**Flow:**
1. Receives refresh token from user
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()`
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()`
4. Issues new access and refresh tokens via `TokenService`
5. Returns new token pair
1. Receives a user ID via `ResendConfirmationEmailCommand`
2. Looks up the user and checks they aren't already verified
3. Generates a fresh confirmation token via `ITokenService`
4. Sends `SendResendConfirmationEmailCommand` over MediatR, handled by the
`Features.Emails` slice, with no direct project reference between the two
slices
#### [AuthController](API.Core/Controllers/AuthController.cs)
#### [RefreshTokenHandler](../../web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs)
**Flow:**
1. Receives a refresh token via `RefreshTokenCommand`
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the
token, retrieves the user account via `IAuthRepository.GetUserByIdAsync()`,
and issues a new access/refresh token pair
3. Maps the result onto `LoginPayload`
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
**Endpoints:**
- `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Authenticate user
- `POST /api/auth/confirm?token=...` - Confirm email
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
- `POST /api/auth/refresh` - Refresh access token
## Validation Security
@@ -158,32 +172,44 @@ Validation failures return HTTP 401 Unauthorized:
1. **Generation**: During user registration (30-minute validity)
2. **Delivery**: Emailed to user in confirmation link
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
4. **Validation**: Validated by ConfirmationService
4. **Validation**: Validated by `ConfirmUserHandler`
5. **Completion**: User account marked as confirmed
6. **Expiration**: Token becomes invalid after 30 minutes
## Testing
All of the following live in `Features.Auth.Tests`.
### Unit Tests
**TokenValidationService.test.cs**
**Services/TokenServiceValidationTests.cs**
- Happy path: Valid token extraction
- Error cases: Invalid, expired, malformed tokens
- Missing/invalid claims scenarios
**RefreshTokenService.test.cs**
**Services/TokenServiceRefreshTests.cs**
- Successful refresh with valid token
- Invalid/expired refresh token rejection
- Non-existent user handling
**ConfirmationService.test.cs**
**Commands/RefreshTokenHandlerTests.cs**
- Verifies the handler maps `ITokenService.RefreshTokenAsync()`'s result onto
`LoginPayload`
**Commands/ConfirmUserHandlerTests.cs**
- Successful confirmation with valid token
- Token validation failures
- User not found scenarios
**Commands/ResendConfirmationEmailHandlerTests.cs**
- Sends a fresh confirmation email when the user exists and is unverified
- No-ops when the user doesn't exist or is already verified
### BDD Tests (Reqnroll)
**TokenRefresh.feature**

View File

@@ -49,8 +49,10 @@ CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
# provider (e.g., SendGrid, Mailgun, Amazon SES).
SMTP_HOST=mailpit
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USERNAME=ayerble
SMTP_PASSWORD=checkers
SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
WEBSITE_BASE_URL=http://localhost:3000

View File

@@ -1,5 +1,5 @@
{
"printWidth": 80,
"printWidth": 100,
"useTabs": false,
"indentSize": 4,
"endOfLine": "lf",

View File

@@ -8,15 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.AspNetCore.OpenApi"
Version="9.0.11"
/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference
Include="FluentValidation.AspNetCore"
Version="11.3.0"
/>
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="MediatR" Version="12.4.1" />
</ItemGroup>
<ItemGroup>
@@ -26,13 +21,14 @@
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
<ProjectReference Include="..\..\Service\Service.Breweries\Service.Breweries.csproj" />
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,13 +1,22 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using API.Core.Contracts.Common;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Shared.Contracts;
namespace API.Core.Authentication;
/// <summary>
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
/// </summary>
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
public class JwtAuthenticationHandler(
IOptionsMonitor<JwtAuthenticationOptions> options,
ILoggerFactory logger,
@@ -16,71 +25,76 @@ public class JwtAuthenticationHandler(
IConfiguration configuration
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
{
/// <summary>
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
/// </summary>
/// <remarks>
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
/// when tokens are issued. Authentication fails if the secret is not configured, the
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
/// token fails validation.
/// </remarks>
/// <returns>
/// A successful <see cref="AuthenticateResult" /> containing the validated
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
/// on success, or a failure result describing why authentication could not be completed.
/// </returns>
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Use the same access-token secret source as TokenService to avoid mismatched validation.
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration["Jwt:SecretKey"];
}
if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured");
}
// Check if Authorization header exists
if (
!Request.Headers.TryGetValue(
"Authorization",
out var authHeaderValue
)
)
{
if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString();
if (
!authHeader.StartsWith(
"Bearer ",
StringComparison.OrdinalIgnoreCase
)
)
{
return AuthenticateResult.Fail(
"Invalid authorization header format"
);
}
string authHeader = authHeaderValue.ToString();
if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return AuthenticateResult.Fail("Invalid authorization header format");
var token = authHeader.Substring("Bearer ".Length).Trim();
string token = authHeader.Substring("Bearer ".Length).Trim();
try
{
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token,
secret
);
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
return AuthenticateResult.Fail(
$"Token validation failed: {ex.Message}"
);
return AuthenticateResult.Fail($"Token validation failed: {ex.Message}");
}
}
/// <summary>
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
/// </summary>
/// <param name="properties">The authentication properties associated with the challenge.</param>
/// <returns>A task that completes once the response body has been written.</returns>
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.ContentType = "application/json";
Response.StatusCode = 401;
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
ResponseBody response = new()
{
Message = "Unauthorized: Invalid or missing authentication token",
};
await Response.WriteAsJsonAsync(response);
}
}
/// <summary>
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
/// </summary>
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }

View File

@@ -1,21 +0,0 @@
using Domain.Entities;
using Org.BouncyCastle.Asn1.Cms;
namespace API.Core.Contracts.Auth;
public record LoginPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken
);
public record RegistrationPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken,
bool ConfirmationEmailSent
);
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -1,20 +0,0 @@
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Contracts.Auth;
public record LoginRequest
{
public string Username { get; init; } = default!;
public string Password { get; init; } = default!;
}
public class LoginRequestValidator : AbstractValidator<LoginRequest>
{
public LoginRequestValidator()
{
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
}
}

View File

@@ -1,19 +0,0 @@
using FluentValidation;
namespace API.Core.Contracts.Auth;
public record RefreshTokenRequest
{
public string RefreshToken { get; init; } = default!;
}
public class RefreshTokenRequestValidator
: AbstractValidator<RefreshTokenRequest>
{
public RefreshTokenRequestValidator()
{
RuleFor(x => x.RefreshToken)
.NotEmpty()
.WithMessage("Refresh token is required");
}
}

View File

@@ -1,41 +0,0 @@
namespace API.Core.Contracts.Breweries;
public class BreweryLocationCreateDto
{
public Guid CityId { get; set; }
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
public byte[]? Coordinates { get; set; }
}
public class BreweryLocationDto
{
public Guid BreweryPostLocationId { get; set; }
public Guid BreweryPostId { get; set; }
public Guid CityId { get; set; }
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string PostalCode { get; set; } = string.Empty;
public byte[]? Coordinates { get; set; }
}
public class BreweryCreateDto
{
public Guid PostedById { get; set; }
public string BreweryName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public BreweryLocationCreateDto Location { get; set; } = null!;
}
public class BreweryDto
{
public Guid BreweryPostId { get; set; }
public Guid PostedById { get; set; }
public string BreweryName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public byte[]? Timer { get; set; }
public BreweryLocationDto? Location { get; set; }
}

View File

@@ -1,12 +0,0 @@
namespace API.Core.Contracts.Common;
public record ResponseBody<T>
{
public required string Message { get; init; }
public required T Payload { get; init; }
}
public record ResponseBody
{
public required string Message { get; init; }
}

View File

@@ -1,118 +0,0 @@
using API.Core.Contracts.Auth;
using API.Core.Contracts.Common;
using Domain.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Service.Auth;
namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(
IRegisterService registerService,
ILoginService loginService,
IConfirmationService confirmationService,
ITokenService tokenService
) : ControllerBase
{
[AllowAnonymous]
[HttpPost("register")]
public async Task<ActionResult<UserAccount>> Register(
[FromBody] RegisterRequest req
)
{
var rtn = await registerService.RegisterAsync(
new UserAccount
{
UserAccountId = Guid.Empty,
Username = req.Username,
FirstName = req.FirstName,
LastName = req.LastName,
Email = req.Email,
DateOfBirth = req.DateOfBirth,
},
req.Password
);
var response = new ResponseBody<RegistrationPayload>
{
Message = "User registered successfully.",
Payload = new RegistrationPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken,
rtn.EmailSent
),
};
return Created("/", response);
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult> Login([FromBody] LoginRequest req)
{
var rtn = await loginService.LoginAsync(req.Username, req.Password);
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Logged in successfully.",
Payload = new LoginPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken
),
}
);
}
[HttpPost("confirm")]
public async Task<ActionResult> Confirm([FromQuery] string token)
{
var rtn = await confirmationService.ConfirmUserAsync(token);
return Ok(
new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + rtn.UserId + " is confirmed.",
Payload = new ConfirmationPayload(
rtn.UserId,
rtn.ConfirmedAt
),
}
);
}
[HttpPost("confirm/resend")]
public async Task<ActionResult> ResendConfirmation([FromQuery] Guid userId)
{
await confirmationService.ResendConfirmationEmailAsync(userId);
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
}
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult> Refresh(
[FromBody] RefreshTokenRequest req
)
{
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Token refreshed successfully.",
Payload = new LoginPayload(
rtn.UserAccount.UserAccountId,
rtn.UserAccount.Username,
rtn.RefreshToken,
rtn.AccessToken
),
}
);
}
}
}

View File

@@ -1,129 +0,0 @@
using API.Core.Contracts.Breweries;
using API.Core.Contracts.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Service.Breweries;
namespace API.Core.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class BreweryController(IBreweryService breweryService) : ControllerBase
{
[AllowAnonymous]
[HttpGet("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
{
var brewery = await breweryService.GetByIdAsync(id);
if (brewery is null)
return NotFound(new ResponseBody { Message = $"Brewery with ID {id} not found." });
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery retrieved successfully.",
Payload = MapToDto(brewery),
});
}
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
[FromQuery] int? limit,
[FromQuery] int? offset)
{
var breweries = await breweryService.GetAllAsync(limit, offset);
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
{
Message = "Breweries retrieved successfully.",
Payload = breweries.Select(MapToDto),
});
}
[HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] BreweryCreateDto dto)
{
var request = new BreweryCreateRequest(
dto.PostedById,
dto.BreweryName,
dto.Description,
new BreweryLocationCreateRequest(
dto.Location.CityId,
dto.Location.AddressLine1,
dto.Location.AddressLine2,
dto.Location.PostalCode,
dto.Location.Coordinates
)
);
var result = await breweryService.CreateAsync(request);
if (!result.Success)
return BadRequest(new ResponseBody { Message = result.Message });
return Created($"/api/brewery/{result.Brewery.BreweryPostId}", new ResponseBody<BreweryDto>
{
Message = "Brewery created successfully.",
Payload = MapToDto(result.Brewery),
});
}
[HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] BreweryDto dto)
{
if (dto.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
var request = new BreweryUpdateRequest(
dto.BreweryPostId,
dto.PostedById,
dto.BreweryName,
dto.Description,
dto.Location is null ? null : new BreweryLocationUpdateRequest(
dto.Location.BreweryPostLocationId,
dto.Location.CityId,
dto.Location.AddressLine1,
dto.Location.AddressLine2,
dto.Location.PostalCode,
dto.Location.Coordinates
)
);
var result = await breweryService.UpdateAsync(request);
if (!result.Success)
return BadRequest(new ResponseBody { Message = result.Message });
return Ok(new ResponseBody<BreweryDto>
{
Message = "Brewery updated successfully.",
Payload = MapToDto(result.Brewery),
});
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{
await breweryService.DeleteAsync(id);
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
}
private static BreweryDto MapToDto(Domain.Entities.BreweryPost b) => new()
{
BreweryPostId = b.BreweryPostId,
PostedById = b.PostedById,
BreweryName = b.BreweryName,
Description = b.Description,
CreatedAt = b.CreatedAt,
UpdatedAt = b.UpdatedAt,
Timer = b.Timer,
Location = b.Location is null ? null : new BreweryLocationDto
{
BreweryPostLocationId = b.Location.BreweryPostLocationId,
BreweryPostId = b.Location.BreweryPostId,
CityId = b.Location.CityId,
AddressLine1 = b.Location.AddressLine1,
AddressLine2 = b.Location.AddressLine2,
PostalCode = b.Location.PostalCode,
Coordinates = b.Location.Coordinates,
},
};
}

View File

@@ -1,16 +1,27 @@
using Microsoft.AspNetCore.Mvc;
namespace API.Core.Controllers
{
namespace API.Core.Controllers;
/// <summary>
/// Handles requests that do not match any other route, used as the application's fallback controller.
/// </summary>
/// <remarks>
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
/// <c>Program.cs</c>.
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
public class NotFoundController : ControllerBase
{
/// <summary>
/// Returns a generic 404 response for any request that did not match a defined route.
/// </summary>
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
[HttpGet("404")] //required
public IActionResult Handle404()
{
return NotFound(new { message = "Route not found." });
}
}
}

View File

@@ -1,20 +1,31 @@
using System.Security.Claims;
using API.Core.Contracts.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace API.Core.Controllers;
/// <summary>
/// Sample controller demonstrating an endpoint that requires JWT authentication.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class ProtectedController : ControllerBase
{
/// <summary>
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
/// </summary>
/// <returns>
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
/// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
/// </returns>
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value;
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok(
new ResponseBody<object>

View File

@@ -1,28 +0,0 @@
using Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Service.UserManagement.User;
namespace API.Core.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class UserController(IUserService userService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll(
[FromQuery] int? limit,
[FromQuery] int? offset
)
{
var users = await userService.GetAllAsync(limit, offset);
return Ok(users);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<UserAccount>> GetById(Guid id)
{
var user = await userService.GetByIdAsync(id);
return Ok(user);
}
}
}

View File

@@ -8,16 +8,35 @@ EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# Copy the solution file and every project file first (preserving their real relative paths, since
# ProjectReference paths are resolved relative to the referencing .csproj's location), so the
# `dotnet restore` layer stays cached as long as no project file or package reference changes,
# independent of source-only edits. Restoring against Core.slnx avoids hand-maintaining a per-project
# dependency list here that silently drifts out of sync as ProjectReferences change.
COPY ["Core.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
RUN dotnet restore "API/API.Core/API.Core.csproj"
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Core.slnx"
COPY . .
WORKDIR "/src/API/API.Core"
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -1,30 +1,89 @@
// API.Core/Filters/GlobalExceptionFilter.cs
using API.Core.Contracts.Common;
using Domain.Exceptions;
using FluentValidation;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using Shared.Contracts;
using ValidationException = FluentValidation.ValidationException;
namespace API.Core;
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
: IExceptionFilter
/// <summary>
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
/// consistent JSON error responses with appropriate HTTP status codes.
/// </summary>
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
{
/// <summary>
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
/// marking the exception as handled.
/// </summary>
/// <remarks>
/// Maps exception types to responses as follows:
/// <list type="bullet">
/// <item>
/// <description>
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
/// validation error messages.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
/// message.
/// </description>
/// </item>
/// <item>
/// <description>
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
/// <see cref="ResponseBody" /> message.
/// </description>
/// </item>
/// <item>
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
/// </item>
/// </list>
/// </remarks>
/// <param name="context">
/// The context for the unhandled exception, including the exception itself and the result to
/// populate.
/// </param>
public void OnException(ExceptionContext context)
{
logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception)
{
case FluentValidation.ValidationException fluentValidationException:
var errors = fluentValidationException
case ValidationException fluentValidationException:
Dictionary<string, string[]> errors = fluentValidationException
.Errors.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
context.Result = new BadRequestObjectResult(
new { message = "Validation failed", errors }
@@ -33,9 +92,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case ConflictException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 409,
};
@@ -43,9 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case NotFoundException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 404,
};
@@ -53,9 +108,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case UnauthorizedException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 401,
};
@@ -63,9 +116,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case ForbiddenException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 403,
};
@@ -83,9 +134,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break;
case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult(
new ResponseBody { Message = ex.Message }
)
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
{
StatusCode = 400,
};
@@ -94,10 +143,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
default:
context.Result = new ObjectResult(
new ResponseBody
{
Message = "An unexpected error occurred",
}
new ResponseBody { Message = "An unexpected error occurred" }
)
{
StatusCode = 500,

View File

@@ -1,26 +1,30 @@
using API.Core;
using API.Core.Authentication;
using Features.Auth.Controllers;
using Features.Auth.DependencyInjection;
using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection;
using Features.Emails.Services;
using Features.UserManagement.Controllers;
using Features.UserManagement.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt;
using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Infrastructure.Repository.Breweries;
using Service.Auth;
using Service.Emails;
using Service.UserManagement.User;
using Infrastructure.Sql;
using Shared.Application.Behaviors;
var builder = WebApplication.CreateBuilder(args);
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter
builder.Services.AddControllers(options =>
builder
.Services.AddControllers(options =>
{
options.Filters.Add<GlobalExceptionFilter>();
});
})
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -28,8 +32,23 @@ builder.Services.AddOpenApi();
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddFluentValidationAutoValidation();
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
// Add health checks
builder.Services.AddHealthChecks();
@@ -37,32 +56,20 @@ builder.Services.AddHealthChecks();
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
if (!builder.Environment.IsProduction())
{
builder.Logging.AddDebug();
}
// Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton<
ISqlConnectionFactory,
DefaultSqlConnectionFactory
>();
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>();
builder.Services.AddScoped<IRegisterService, RegisterService>();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails();
// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because
// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();
@@ -70,14 +77,11 @@ builder.Services.AddScoped<GlobalExceptionFilter>();
// Configure JWT Authentication
builder
.Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
"JWT",
options => { }
);
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
builder.Services.AddAuthorization();
var app = builder.Build();
WebApplication app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
@@ -95,7 +99,7 @@ app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound");
// Graceful shutdown handling
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
app.Logger.LogInformation("Application is shutting down gracefully...");

View File

@@ -24,10 +24,7 @@
/>
<!-- ASP.NET Core integration testing -->
<PackageReference
Include="Microsoft.AspNetCore.Mvc.Testing"
Version="9.0.1"
/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
@@ -42,5 +39,6 @@
<ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,17 +1,32 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# See API.Core/Dockerfile for why this copies every project file (with real relative paths) and
# restores against Core.slnx rather than hand-listing API.Specs' transitive dependencies.
COPY ["Core.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
COPY ["Features/Features.Emails.Tests/Features.Emails.Tests.csproj", "Features/Features.Emails.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"]
RUN dotnet restore "API/API.Specs/API.Specs.csproj"
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Core.slnx"
COPY . .
WORKDIR "/src/API/API.Specs"
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -2,6 +2,7 @@ Feature: User Account Confirmation
As a newly registered user
I want to confirm my email address via a validation token
So that my account is fully activated
Scenario: Successful confirmation with valid token
Given the API is running
And I have registered a new account

View File

@@ -1,23 +1,20 @@
using Domain.Entities;
using Service.Emails;
using Features.Emails.Services;
namespace API.Specs.Mocks;
public class MockEmailService : IEmailService
public class MockEmailDispatcher : IEmailDispatcher
{
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
public Task SendRegistrationEmailAsync(
UserAccount createdUser,
string confirmationToken
)
public Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
{
SentRegistrationEmails.Add(
new RegistrationEmail
{
UserAccount = createdUser,
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
}
@@ -27,14 +24,16 @@ public class MockEmailService : IEmailService
}
public Task SendResendConfirmationEmailAsync(
UserAccount user,
string firstName,
string email,
string confirmationToken
)
{
SentResendConfirmationEmails.Add(
new ResendConfirmationEmail
{
UserAccount = user,
FirstName = firstName,
Email = email,
ConfirmationToken = confirmationToken,
SentAt = DateTime.UtcNow,
}
@@ -51,14 +50,16 @@ public class MockEmailService : IEmailService
public class RegistrationEmail
{
public UserAccount UserAccount { get; init; } = null!;
public string FirstName { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}
public class ResendConfirmationEmail
{
public UserAccount UserAccount { get; init; } = null!;
public string FirstName { get; init; } = string.Empty;
public string Email { get; init; } = string.Empty;
public string ConfirmationToken { get; init; } = string.Empty;
public DateTime SentAt { get; init; }
}

View File

@@ -10,12 +10,7 @@ public class MockEmailProvider : IEmailProvider
{
public List<SentEmail> SentEmails { get; } = new();
public Task SendAsync(
string to,
string subject,
string body,
bool isHtml = true
)
public Task SendAsync(string to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(
new SentEmail
@@ -31,12 +26,7 @@ public class MockEmailProvider : IEmailProvider
return Task.CompletedTask;
}
public Task SendAsync(
IEnumerable<string> to,
string subject,
string body,
bool isHtml = true
)
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(
new SentEmail

View File

@@ -1,5 +1,5 @@
using System.Text;
using System.Text.Json;
using API.Specs;
using FluentAssertions;
using Reqnroll;
@@ -15,14 +15,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
private HttpClient GetClient()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
}
var factory = scenario.TryGetValue<TestApiFactory>(
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out var f
out TestApiFactory? f
)
? f
: new TestApiFactory();
@@ -46,37 +44,27 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string jsonBody
)
{
var client = GetClient();
HttpClient client = GetClient();
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
{
Content = new StringContent(
jsonBody,
System.Text.Encoding.UTF8,
"application/json"
),
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
};
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
}
[When("I send an HTTP request {string} to {string}")]
public async Task WhenISendAnHttpRequestStringToString(
string method,
string url
)
public async Task WhenISendAnHttpRequestStringToString(string method, string url)
{
var client = GetClient();
var requestMessage = new HttpRequestMessage(
new HttpMethod(method),
url
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
HttpClient client = GetClient();
HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
@@ -86,7 +74,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
((int)response!.StatusCode).Should().Be(expected);
@@ -96,57 +84,42 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode);
}
[Then("the response JSON should have {string} equal {string}")]
public void ThenTheResponseJsonShouldHaveStringEqualString(
string field,
string expected
)
public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
if (!root.TryGetProperty(field, out JsonElement value))
{
root.TryGetProperty("payload", out var payloadElem)
root.TryGetProperty("payload", out JsonElement payloadElem)
.Should()
.BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
field
);
payloadElem
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
payloadElem
.TryGetProperty(field, out value)
.Should()
.BeTrue(
"Expected field '{0}' to be present inside 'payload'",
field
);
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
}
value
.ValueKind.Should()
.Be(
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
value.GetString().Should().Be(expected);
}
@@ -157,45 +130,33 @@ public class ApiGeneralSteps(ScenarioContext scenario)
)
{
scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement;
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (!root.TryGetProperty(field, out var value))
if (!root.TryGetProperty(field, out JsonElement value))
{
root.TryGetProperty("payload", out var payloadElem)
root.TryGetProperty("payload", out JsonElement payloadElem)
.Should()
.BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'",
field
);
payloadElem
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
payloadElem
.TryGetProperty(field, out value)
.Should()
.BeTrue(
"Expected field '{0}' to be present inside 'payload'",
field
);
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
}
value
.ValueKind.Should()
.Be(
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
var actualValue = value.GetString();
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
string? actualValue = value.GetString();
actualValue
.Should()
.Contain(

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,12 @@
using System.Collections.Generic;
using API.Specs.Mocks;
using Features.Emails.Services;
using Infrastructure.Email;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Service.Emails;
namespace API.Specs
{
namespace API.Specs;
public class TestApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -18,29 +16,24 @@ namespace API.Specs
builder.ConfigureServices(services =>
{
// Replace the real email provider with mock for testing
var emailProviderDescriptor = services.SingleOrDefault(d =>
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
if (emailProviderDescriptor != null)
{
services.Remove(emailProviderDescriptor);
}
services.AddScoped<IEmailProvider, MockEmailProvider>();
// Replace the real email service with mock for testing
var emailServiceDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailService)
// Replace the real email dispatcher with mock for testing
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailDispatcher)
);
if (emailServiceDescriptor != null)
{
services.Remove(emailServiceDescriptor);
}
if (emailDispatcherDescriptor != null)
services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailService, MockEmailService>();
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
});
}
}
}

View File

@@ -18,15 +18,20 @@
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.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" />
<Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
</Folder>
<Folder Name="/Service/">
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
<Project Path="Service/Service.Auth/Service.Auth.csproj" />
<Project Path="Service/Service.Breweries/Service.Breweries.csproj" />
<Folder Name="/Features/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj"/>
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/>
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/>
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/>
<Project Path="Features/Features.Auth/Features.Auth.csproj"/>
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj"/>
<Project Path="Features/Features.Emails/Features.Emails.csproj"/>
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj"/>
</Folder>
<Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj"/>
<Project Path="Shared/Shared.Application/Shared.Application.csproj"/>
</Folder>
</Solution>

View File

@@ -1,95 +1,136 @@
using System.Data;
using System.Reflection;
using DbUp;
using DbUp.Engine;
using Microsoft.Data.SqlClient;
namespace Database.Migrations;
/// <summary>
/// Entry point for the database migration runner. Reads connection details from
/// environment variables, optionally clears and recreates the target database, and
/// applies all SQL migration scripts embedded in this assembly using DbUp.
/// </summary>
public static class Program
{
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
private static readonly string connectionString = BuildConnectionString();
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
/// environment variables.
/// </summary>
/// <param name="databaseName">
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
/// <c>DB_NAME</c> environment variable.
/// </param>
/// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
/// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
/// </exception>
private static string BuildConnectionString(string? databaseName = null)
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName
string dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True";
string trustServerCertificate =
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true
Encrypt = true,
};
return builder.ConnectionString;
}
private static readonly string connectionString = BuildConnectionString();
private static readonly string masterConnectionString = BuildConnectionString("master");
/// <summary>
/// Applies all pending SQL migration scripts embedded in this assembly to the target
/// database using DbUp, logging progress to the console.
/// </summary>
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
private static bool DeployMigrations()
{
var upgrader = DeployChanges
UpgradeEngine? upgrader = DeployChanges
.To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
return result.Successful;
}
/// <summary>
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
/// mode with rollback to terminate any existing connections.
/// </summary>
/// <returns>
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
/// if an error occurred while connecting or dropping the database.
/// </returns>
private static bool ClearDatabase()
{
var myConn = new SqlConnection(masterConnectionString);
SqlConnection myConn = new(masterConnectionString);
try
{
myConn.Open();
// First, set the database to single user mode to close all connections
var setModeCommand = new SqlCommand(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn);
SqlCommand setModeCommand = new(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') "
+ "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn
);
try
{
setModeCommand.ExecuteNonQuery();
Console.WriteLine("Database set to single user mode.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
}
// Then drop the database
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
try
{
dropCommand.ExecuteNonQuery();
Console.WriteLine("Database cleared successfully.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error dropping database: {ex}");
return false;
}
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error clearing database: {ex}");
return false;
@@ -97,50 +138,61 @@ public static class Program
finally
{
if (myConn.State == ConnectionState.Open)
{
myConn.Close();
}
}
return true;
}
/// <summary>
/// Creates the <c>Biergarten</c> database on the master connection if it does not
/// already exist. Errors encountered while creating the database are logged but do
/// not stop execution.
/// </summary>
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
private static bool CreateDatabaseIfNotExists()
{
var myConn = new SqlConnection(masterConnectionString);
SqlConnection myConn = new(masterConnectionString);
const string str = """
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten]
""";
var myCommand = new SqlCommand(str, myConn);
SqlCommand myCommand = new(str, myConn);
try
{
myConn.Open();
myCommand.ExecuteNonQuery();
Console.WriteLine("Database creation command executed successfully.");
}
catch (System.Exception ex)
catch (Exception ex)
{
Console.WriteLine($"Error creating database: {ex}");
}
finally
{
if (myConn.State == ConnectionState.Open)
{
myConn.Close();
}
}
return true;
}
/// <summary>
/// Migration runner entry point. Optionally clears the existing database when the
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
/// database exists, then deploys all pending migrations.
/// </summary>
/// <param name="args">Command-line arguments (unused).</param>
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
public static int Main(string[] args)
{
Console.WriteLine("Starting database migrations...");
try
{
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
if (clearDatabase == "true")
{
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
@@ -148,19 +200,17 @@ public static class Program
}
CreateDatabaseIfNotExists();
var success = DeployMigrations();
bool success = DeployMigrations();
if (success)
{
Console.WriteLine("Database migrations completed successfully.");
return 0;
}
else
{
Console.WriteLine("Database migrations failed.");
return 1;
}
}
catch (Exception ex)
{
Console.WriteLine("An error occurred during database migrations:");

View File

@@ -78,7 +78,8 @@ CREATE TABLE Photo -- All photos must be linked to a user account, you cannot de
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
CREATE
NONCLUSTERED INDEX IX_Photo_UploadedByID
ON Photo(UploadedByID);
----------------------------------------------------------------------------
@@ -110,7 +111,8 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);
----------------------------------------------------------------------------
@@ -140,7 +142,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
UNIQUE (UserAccountID)
);
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID);
----------------------------------------------------------------------------
@@ -178,10 +181,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash);
@@ -216,13 +221,16 @@ CREATE TABLE UserFollow
ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount
CHECK (UserAccountID != FollowingID)
CHECK (UserAccountID != FollowingID
)
);
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
CREATE
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
ON UserFollow(UserAccountID, FollowingID);
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID);
----------------------------------------------------------------------------
@@ -274,7 +282,8 @@ CREATE TABLE StateProvince
REFERENCES Country (CountryID)
);
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID);
----------------------------------------------------------------------------
@@ -299,7 +308,8 @@ CREATE TABLE City
REFERENCES StateProvince (StateProvinceID)
);
CREATE NONCLUSTERED INDEX IX_City_StateProvince
CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID);
----------------------------------------------------------------------------
@@ -332,7 +342,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
ON DELETE NO ACTION
);
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID);
----------------------------------------------------------------------------
@@ -373,10 +384,12 @@ CREATE TABLE BreweryPostLocation
REFERENCES City (CityID)
);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID);
-- To assess when the time comes:
@@ -422,10 +435,12 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID);
----------------------------------------------------------------------------
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
CHECK (IBU >= 0 AND IBU <= 120)
);
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON BeerPost(PostedByID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON BeerPost(BeerStyleID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON BeerPost(BrewedByID);
----------------------------------------------------------------------------
@@ -543,10 +561,12 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
ON DELETE CASCADE
);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID);
----------------------------------------------------------------------------
@@ -588,8 +608,10 @@ CREATE TABLE BeerPostComment
CHECK (Rating BETWEEN 1 AND 5)
);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID);

View File

@@ -1,11 +1,14 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
CREATE
OR
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
(
@CountryCode NVARCHAR(2)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE @CountryId UNIQUEIDENTIFIER;
DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID
FROM dbo.Country

View File

@@ -1,11 +1,14 @@
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
CREATE
OR
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
(
@StateProvinceCode NVARCHAR(6)
)
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
DECLARE @StateProvinceId UNIQUEIDENTIFIER;
DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode;

View File

@@ -1,5 +1,6 @@
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
CREATE
OR
ALTER PROCEDURE usp_CreateUserAccount
(
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
@Username VARCHAR (64),
@@ -10,27 +11,24 @@ CREATE OR ALTER PROCEDURE usp_CreateUserAccount
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
DECLARE
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
INSERT INTO UserAccount
(
Username,
(Username,
FirstName,
LastName,
DateOfBirth,
Email
)
Email)
OUTPUT INSERTED.UserAccountID INTO @Inserted
VALUES
(
@Username,
@FirstName,
@LastName,
@DateOfBirth,
@Email
@Username, @FirstName, @LastName, @DateOfBirth, @Email
);
SELECT @UserAccountId = UserAccountID FROM @Inserted;
SELECT @UserAccountId = UserAccountID
FROM @Inserted;
END;

View File

@@ -1,20 +1,24 @@
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
CREATE
OR
ALTER PROCEDURE usp_DeleteUserAccount
(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON
SET
NOCOUNT ON
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
BEGIN
RAISERROR('UserAccount with the specified ID does not exist.', 16,
RAISERROR
('UserAccount with the specified ID does not exist.', 16,
1);
ROLLBACK TRANSACTION
RETURN
END
DELETE FROM UserAccount
DELETE
FROM UserAccount
WHERE UserAccountId = @UserAccountId;
END;

View File

@@ -1,7 +1,10 @@
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
CREATE
OR
ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,9 +1,12 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
CREATE
OR
ALTER PROCEDURE usp_GetUserAccountByEmail(
@Email VARCHAR (128)
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,9 +1,12 @@
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
CREATE
OR
ALTER PROCEDURE USP_GetUserAccountById(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,9 +1,12 @@
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
CREATE
OR
ALTER PROCEDURE usp_GetUserAccountByUsername(
@Username VARCHAR (64)
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT UserAccountID,
Username,

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
CREATE
OR
ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR (64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@@ -19,7 +21,8 @@ BEGIN
Email = @Email
WHERE UserAccountId = @UserAccountId;
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
BEGIN
THROW
50001, 'UserAccount with the specified ID does not exist.', 1;

View File

@@ -1,17 +1,20 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
CREATE
OR
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON;
SET
NOCOUNT ON;
SELECT
UserCredentialId,
SELECT UserCredentialId,
UserAccountId,
Hash,
IsRevoked,
CreatedAt,
RevokedAt
FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0;
WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END;

View File

@@ -1,15 +1,21 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
CREATE
OR
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
CREATE
OR
ALTER PROCEDURE dbo.USP_RegisterUser(
@Username VARCHAR (64),
@FirstName NVARCHAR(128),
@LastName NVARCHAR(128),
@@ -8,12 +10,16 @@ CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT,
@@ -23,18 +29,22 @@ BEGIN
@DateOfBirth = @DateOfBirth,
@Email = @Email;
IF @UserAccountId_ IS NULL
IF
@UserAccountId_ IS NULL
BEGIN
THROW 50000, 'Failed to create user account.', 1;
THROW
50000, 'Failed to create user account.', 1;
END
INSERT INTO dbo.UserCredential
(UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash);
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
BEGIN
THROW 50002, 'Failed to create user credential.', 1;
THROW
50002, 'Failed to create user credential.', 1;
END
COMMIT TRANSACTION;

View File

@@ -1,12 +1,17 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
CREATE
OR
ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_

View File

@@ -1,17 +1,24 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
@VerificationDateTime DATETIME = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF @VerificationDateTime IS NULL
IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE();
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF @@ROWCOUNT = 0
IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification

View File

@@ -1,26 +1,36 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCity(
@CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF @StateProvinceId IS NULL
BEGIN
THROW 50001, 'State/province does not exist', 1;
TRANSACTION
DECLARE
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF
@StateProvinceId IS NULL
BEGIN
THROW
50001, 'State/province does not exist', 1;
END
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.City
WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId)
BEGIN
THROW 50002, 'City already exists.', 1;
THROW
50002, 'City already exists.', 1;
END
INSERT INTO dbo.City

View File

@@ -1,21 +1,26 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateCountry(
@CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country
(CountryName, ISO3166_1)
VALUES
(@CountryName, @ISO3166_1);
VALUES (@CountryName, @ISO3166_1);
COMMIT TRANSACTION;
END;

View File

@@ -1,27 +1,34 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateStateProvince(
@StateProvinceName NVARCHAR(100),
@ISO3166_2 NVARCHAR(6),
@CountryCode NVARCHAR(2)
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF EXISTS (SELECT 1
IF
EXISTS (SELECT 1
FROM dbo.StateProvince
WHERE ISO3166_2 = @ISO3166_2)
RETURN;
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF @CountryId IS NULL
DECLARE
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
IF
@CountryId IS NULL
BEGIN
THROW 50001, 'Country does not exist', 1;
THROW
50001, 'Country does not exist', 1;
END
INSERT INTO dbo.StateProvince
(StateProvinceName, ISO3166_2, CountryID)
VALUES
(@StateProvinceName, @ISO3166_2, @CountryId);
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
END;

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
CREATE
OR
ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER,
@@ -10,29 +12,38 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1
IF
NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION;
BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID)

View File

@@ -0,0 +1,20 @@
CREATE
OR
ALTER PROCEDURE dbo.USP_DeleteBrewery
@BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SET
NOCOUNT ON;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
DELETE
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID;
END

View File

@@ -0,0 +1,30 @@
CREATE
OR
ALTER PROCEDURE dbo.USP_GetAllBreweries(
@Limit INT = NULL,
@Offset INT = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SELECT bp.BreweryPostID,
bp.PostedByID,
bp.BreweryName,
bp.Description,
bp.CreatedAt,
bp.UpdatedAt,
bp.Timer,
bpl.BreweryPostLocationID,
bpl.CityID,
bpl.AddressLine1,
bpl.AddressLine2,
bpl.PostalCode,
bpl.Coordinates
FROM dbo.BreweryPost bp
LEFT JOIN dbo.BreweryPostLocation bpl
ON bp.BreweryPostID = bpl.BreweryPostID
ORDER BY bp.CreatedAt DESC
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
END

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
CREATE
OR
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
AS
BEGIN
SELECT *

View File

@@ -0,0 +1,79 @@
CREATE
OR
ALTER PROCEDURE dbo.USP_UpdateBrewery(
@BreweryPostID UNIQUEIDENTIFIER,
@BreweryName NVARCHAR(256),
@Description NVARCHAR(512),
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
@CityID UNIQUEIDENTIFIER = NULL,
@AddressLine1 NVARCHAR(256) = NULL,
@AddressLine2 NVARCHAR(256) = NULL,
@PostalCode NVARCHAR(20) = NULL,
@Coordinates GEOGRAPHY = NULL
)
AS
BEGIN
SET
NOCOUNT ON;
SET
XACT_ABORT ON;
IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1;
IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1;
IF
NOT EXISTS (SELECT 1
FROM dbo.BreweryPost
WHERE BreweryPostID = @BreweryPostID)
THROW 50404, 'Brewery not found.', 1;
IF
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
FROM dbo.City
WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1;
BEGIN
TRANSACTION;
UPDATE dbo.BreweryPost
SET BreweryName = @BreweryName,
Description = @Description,
UpdatedAt = GETDATE()
WHERE BreweryPostID = @BreweryPostID;
IF
@CityID IS NULL
BEGIN
-- No location supplied: clear any existing location for this brewery.
DELETE
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID;
END
ELSE IF EXISTS (SELECT 1
FROM dbo.BreweryPostLocation
WHERE BreweryPostID = @BreweryPostID)
BEGIN
UPDATE dbo.BreweryPostLocation
SET CityID = @CityID,
AddressLine1 = @AddressLine1,
AddressLine2 = @AddressLine2,
PostalCode = @PostalCode,
Coordinates = @Coordinates
WHERE BreweryPostID = @BreweryPostID;
END
ELSE
BEGIN
INSERT INTO dbo.BreweryPostLocation
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
@PostalCode, @Coordinates);
END
COMMIT TRANSACTION;
END

View File

@@ -9,17 +9,12 @@
<ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
<PackageReference
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="dbup" Version="5.0.41" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,7 +2,18 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// Defines a unit of seed data that can be applied to the database. Implementations
/// should be safe to run against an already-seeded database (e.g. by checking for
/// existing data before inserting) and may depend on data created by seeders that run
/// before them.
/// </summary>
internal interface ISeeder
{
/// <summary>
/// Inserts this seeder's data into the database using the supplied open connection.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when seeding is finished.</returns>
Task SeedAsync(SqlConnection connection);
}

View File

@@ -3,22 +3,34 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to
/// associate other entities (e.g. breweries, users) with a geographic location.
/// Countries must be seeded before states/provinces, which must be seeded before
/// cities, since each level references its parent by code. The underlying stored
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
/// against an already-seeded database is safe.
/// </summary>
internal class LocationSeeder : ISeeder
{
private static readonly IReadOnlyList<(
string CountryName,
string CountryCode
)> Countries =
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
[
("Canada", "CA"),
("Mexico", "MX"),
("United States", "US"),
];
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
{
get;
} =
/// <summary>
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
/// and the ISO 3166-1 code of its parent country.
/// </summary>
private static IReadOnlyList<(
string StateProvinceName,
string StateProvinceCode,
string CountryCode
)> States { get; } =
[
("Alabama", "US-AL", "US"),
("Alaska", "US-AK", "US"),
@@ -123,6 +135,10 @@ internal class LocationSeeder : ISeeder
("Ciudad de México", "MX-CMX", "MX"),
];
/// <summary>
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
/// parent state/province.
/// </summary>
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
[
("US-CA", "Los Angeles"),
@@ -240,41 +256,41 @@ internal class LocationSeeder : ISeeder
("MX-ZAC", "Zacatecas"),
];
/// <summary>
/// Seeds all countries, then states/provinces, then cities, in that order, so that
/// each level's parent reference already exists by the time it is created.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when all locations have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection)
{
foreach (var (countryName, countryCode) in Countries)
{
foreach ((string countryName, string countryCode) in Countries)
await CreateCountryAsync(connection, countryName, countryCode);
}
foreach (
var (stateProvinceName, stateProvinceCode, countryCode) in States
)
{
foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
await CreateStateProvinceAsync(
connection,
stateProvinceName,
stateProvinceCode,
countryCode
);
}
foreach (var (stateProvinceCode, cityName) in Cities)
{
foreach ((string stateProvinceCode, string cityName) in Cities)
await CreateCityAsync(connection, cityName, stateProvinceCode);
}
}
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="countryName">The display name of the country.</param>
/// <param name="countryCode">The ISO 3166-1 code of the country.</param>
/// <returns>A task that completes when the country has been created.</returns>
private static async Task CreateCountryAsync(
SqlConnection connection,
string countryName,
string countryCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateCountry",
connection
);
await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CountryName", countryName);
command.Parameters.AddWithValue("@ISO3166_1", countryCode);
@@ -282,6 +298,12 @@ internal class LocationSeeder : ISeeder
await command.ExecuteNonQueryAsync();
}
/// <summary>Creates a single state/province by invoking the <c>dbo.USP_CreateStateProvince</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="stateProvinceName">The display name of the state/province.</param>
/// <param name="stateProvinceCode">The ISO 3166-2 code of the state/province.</param>
/// <param name="countryCode">The ISO 3166-1 code of the parent country, which must already exist.</param>
/// <returns>A task that completes when the state/province has been created.</returns>
private static async Task CreateStateProvinceAsync(
SqlConnection connection,
string stateProvinceName,
@@ -289,37 +311,30 @@ internal class LocationSeeder : ISeeder
string countryCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateStateProvince",
connection
);
await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue(
"@StateProvinceName",
stateProvinceName
);
command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
command.Parameters.AddWithValue("@CountryCode", countryCode);
await command.ExecuteNonQueryAsync();
}
/// <summary>Creates a single city by invoking the <c>dbo.USP_CreateCity</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="cityName">The display name of the city.</param>
/// <param name="stateProvinceCode">The ISO 3166-2 code of the parent state/province, which must already exist.</param>
/// <returns>A task that completes when the city has been created.</returns>
private static async Task CreateCityAsync(
SqlConnection connection,
string cityName,
string stateProvinceCode
)
{
await using var command = new SqlCommand(
"dbo.USP_CreateCity",
connection
);
await using SqlCommand command = new("dbo.USP_CreateCity", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CityName", cityName);
command.Parameters.AddWithValue(
"@StateProvinceCode",
stateProvinceCode
);
command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
await command.ExecuteNonQueryAsync();
}

View File

@@ -1,42 +1,57 @@
using Microsoft.Data.SqlClient;
using DbUp;
using System.Reflection;
using Database.Seed;
using Database.Seed;
using Microsoft.Data.SqlClient;
// Entry point for the database seeding utility. Connects to the target database
// (retrying on transient failures), then runs each registered ISeeder in order to
// populate location and user data.
/// <summary>
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
/// environment variables.
/// </summary>
/// <returns>A fully built SQL Server connection string.</returns>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
/// is not set.
/// </exception>
string BuildConnectionString()
{
var server = Environment.GetEnvironmentVariable("DB_SERVER")
string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = Environment.GetEnvironmentVariable("DB_NAME")
string dbName =
Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER")
string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
?? "True";
string trustServerCertificate =
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder
SqlConnectionStringBuilder builder = new()
{
DataSource = server,
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true
Encrypt = true,
};
return builder.ConnectionString;
}
try
{
var connectionString = BuildConnectionString();
string connectionString = BuildConnectionString();
Console.WriteLine("Attempting to connect to database...");
@@ -46,7 +61,6 @@ try
int retryDelayMs = 2000;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
connection = new SqlConnection(connectionString);
@@ -62,24 +76,17 @@ try
connection?.Dispose();
connection = null;
}
}
if (connection == null)
{
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
}
Console.WriteLine("Starting seeding...");
using (connection)
{
ISeeder[] seeders =
[
new LocationSeeder(),
new UserSeeder(),
];
ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
foreach (var seeder in seeders)
foreach (ISeeder seeder in seeders)
{
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
await seeder.SeedAsync(connection);

View File

@@ -7,12 +7,19 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed;
/// <summary>
/// Seeds user accounts, credentials, and verification records. Creates one fixed
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
/// for testing, followed by a randomly-generated account for each name in
/// <see cref="SeedNames" />, each with a random password and date of birth. Skips
/// adding a verification record for a user that already has one, so this seeder is
/// safe to re-run, though re-running will still attempt to register (and may fail on)
/// users that already exist.
/// </summary>
internal class UserSeeder : ISeeder
{
private static readonly IReadOnlyList<(
string FirstName,
string LastName
)> SeedNames =
/// <summary>The first/last name pairs used to generate seed user accounts.</summary>
private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
[
("Aarya", "Mathews"),
("Aiden", "Wells"),
@@ -116,10 +123,18 @@ internal class UserSeeder : ISeeder
("Zoie", "Armstrong"),
];
/// <summary>
/// Registers a fixed test user account followed by one randomly-generated account
/// per entry in <see cref="SeedNames" />, adding a user verification record for each
/// newly created account that does not already have one. Progress counts are
/// written to the console on completion.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <returns>A task that completes when all users have been seeded.</returns>
public async Task SeedAsync(SqlConnection connection)
{
var generator = new PasswordGenerator();
var rng = new Random();
PasswordGenerator generator = new();
Random rng = new();
int createdUsers = 0;
int createdCredentials = 0;
int createdVerifications = 0;
@@ -129,8 +144,8 @@ internal class UserSeeder : ISeeder
const string firstName = "Test";
const string lastName = "User";
const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01);
var hash = GeneratePasswordHash("password");
DateTime dob = new(1985, 03, 01);
string hash = GeneratePasswordHash("password");
await RegisterUserAsync(
connection,
@@ -142,24 +157,19 @@ internal class UserSeeder : ISeeder
hash
);
}
foreach (var (firstName, lastName) in SeedNames)
foreach ((string firstName, string lastName) in SeedNames)
{
// prepare user fields
var username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng);
string username = $"{firstName[0]}.{lastName}";
string email = $"{firstName}.{lastName}@thebiergarten.app";
DateTime dob = GenerateDateOfBirth(rng);
// generate a password and hash it
string pwd = generator.Generate(
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
);
string pwd = generator.Generate(64, 10, 10);
string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential)
var id = await RegisterUserAsync(
Guid id = await RegisterUserAsync(
connection,
username,
firstName,
@@ -171,9 +181,9 @@ internal class UserSeeder : ISeeder
createdUsers++;
createdCredentials++;
// add user verification
if (await HasUserVerificationAsync(connection, id)) continue;
if (await HasUserVerificationAsync(connection, id))
continue;
await AddUserVerificationAsync(connection, id);
createdVerifications++;
@@ -184,6 +194,18 @@ internal class UserSeeder : ISeeder
Console.WriteLine($"Added {createdVerifications} user verifications.");
}
/// <summary>
/// Registers a new user account and its credential by invoking the
/// <c>dbo.USP_RegisterUser</c> stored procedure.
/// </summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="username">The unique username for the account.</param>
/// <param name="firstName">The user's first name.</param>
/// <param name="lastName">The user's last name.</param>
/// <param name="dateOfBirth">The user's date of birth.</param>
/// <param name="email">The user's email address.</param>
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash" />.</param>
/// <returns>The newly created user account's <see cref="Guid" /> identifier.</returns>
private static async Task<Guid> RegisterUserAsync(
SqlConnection connection,
string username,
@@ -194,10 +216,9 @@ internal class UserSeeder : ISeeder
string hash
)
{
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
await using SqlCommand command = new("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;
@@ -205,17 +226,23 @@ internal class UserSeeder : ISeeder
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return (Guid)result!;
}
/// <summary>
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
/// salt, a degree of parallelism matching the available processor count, 64 MB of
/// memory, and 4 iterations.
/// </summary>
/// <param name="pwd">The plaintext password to hash.</param>
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
private static string GeneratePasswordHash(string pwd)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
{
Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
@@ -227,6 +254,10 @@ internal class UserSeeder : ISeeder
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
}
/// <summary>Checks whether a user verification record already exists for the given user account.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="userAccountId">The user account identifier to check.</param>
/// <returns><c>true</c> if a verification record already exists; otherwise <c>false</c>.</returns>
private static async Task<bool> HasUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
@@ -237,27 +268,31 @@ internal class UserSeeder : ISeeder
FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId;
""";
await using var command = new SqlCommand(sql, connection);
await using SqlCommand command = new(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync();
object? result = await command.ExecuteScalarAsync();
return result is not null;
}
private static async Task AddUserVerificationAsync(
SqlConnection connection,
Guid userAccountId
)
/// <summary>Creates a user verification record by invoking the <c>dbo.USP_CreateUserVerification</c> stored procedure.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="userAccountId">The identifier of the user account to verify.</param>
/// <returns>A task that completes when the verification record has been created.</returns>
private static async Task AddUserVerificationAsync(SqlConnection connection, Guid userAccountId)
{
await using var command = new SqlCommand(
"dbo.USP_CreateUserVerification",
connection
);
await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Generates a random date of birth corresponding to an age between 19 and 48
/// years (inclusive), with a random day offset within that birth year.
/// </summary>
/// <param name="random">The random number source to use.</param>
/// <returns>A randomly generated date of birth.</returns>
private static DateTime GenerateDateOfBirth(Random random)
{
int age = 19 + random.Next(0, 30);

View File

@@ -0,0 +1,15 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
RUN dotnet restore "Core.slnx"
RUN dotnet build "Core.slnx" -c $BUILD_CONFIGURATION
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is
# excluded here since it's a separate BDD/integration suite, run by its own compose service
# against the live, seeded database.
FROM build AS final
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
RUN mkdir -p /app/test-results
ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"]

View File

@@ -1,13 +1,50 @@
namespace Domain.Entities;
/// <summary>
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
/// A user account cannot be deleted while it has an associated brewery post.
/// </summary>
public class BreweryPost
{
/// <summary>
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
/// </summary>
public Guid BreweryPostId { get; set; }
/// <summary>
/// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
/// </summary>
public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery being posted about.
/// </summary>
public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// Free-text description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time the post was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
/// <summary>
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
/// navigation property.
/// </summary>
public BreweryPostLocation? Location { get; set; }
}

View File

@@ -1,13 +1,52 @@
namespace Domain.Entities;
/// <summary>
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
/// deleted.
/// </summary>
public class BreweryPostLocation
{
/// <summary>
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
/// </summary>
public Guid BreweryPostLocationId { get; set; }
/// <summary>
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid BreweryPostId { get; set; }
/// <summary>
/// The primary street address line.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty;
/// <summary>
/// An optional secondary address line (e.g., suite or unit number).
/// </summary>
public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the location.
/// </summary>
public string PostalCode { get; set; } = string.Empty;
/// <summary>
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
/// </summary>
public Guid CityId { get; set; }
/// <summary>
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
/// <c>null</c> if not set.
/// </summary>
public byte[]? Coordinates { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,14 +1,55 @@
namespace Domain.Entities;
/// <summary>
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
/// data.
/// </summary>
public class UserAccount
{
/// <summary>
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; }
/// <summary>
/// The user's unique username, used for login and display. Enforced unique at the database level.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// The user's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty;
/// <summary>
/// The user's last name.
/// </summary>
public string LastName { get; set; } = string.Empty;
/// <summary>
/// The user's email address. Enforced unique at the database level.
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// The date and time the account was created.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// The user's date of birth.
/// </summary>
public DateTime DateOfBirth { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,11 +1,40 @@
namespace Domain.Entities;
/// <summary>
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
/// deleted.
/// </summary>
public class UserCredential
{
/// <summary>
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
/// </summary>
public Guid UserCredentialId { get; set; }
/// <summary>
/// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time the credential was issued.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time after which the credential is no longer valid.
/// </summary>
public DateTime Expiry { get; set; }
/// <summary>
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
/// </summary>
public string Hash { get; set; } = string.Empty;
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,9 +1,31 @@
namespace Domain.Entities;
/// <summary>
/// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
/// and it is deleted automatically when the owning user account is deleted.
/// </summary>
public class UserVerification
{
/// <summary>
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
/// </summary>
public Guid UserVerificationId { get; set; }
/// <summary>
/// Foreign key referencing the verified <see cref="UserAccount" />. Maps to <c>UserAccountID</c>; unique, enforcing a
/// one-to-one relationship.
/// </summary>
public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time at which the user account was verified.
/// </summary>
public DateTime VerificationDateTime { get; set; }
/// <summary>
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
/// been read from the database.
/// </summary>
public byte[]? Timer { get; set; }
}

View File

@@ -1,9 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Moq;
namespace Features.Auth.Tests.Commands;
public class ConfirmUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly ConfirmUserHandler _handler;
private readonly Mock<ITokenService> _tokenServiceMock;
public ConfirmUserHandlerTests()
{
_authRepositoryMock = new Mock<IAuthRepository>();
_tokenServiceMock = new Mock<ITokenService>();
_handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object);
}
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
{
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
return new ValidatedToken(userId, username, principal);
}
[Fact]
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
{
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string confirmationToken = "valid-confirmation-token";
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken);
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
ConfirmationPayload result = await _handler.Handle(
new ConfirmUserCommand(confirmationToken),
CancellationToken.None
);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(userId);
result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
}
[Fact]
public async Task Handle_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
{
const string invalidToken = "invalid-confirmation-token";
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
Func<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
{
Guid userId = Guid.NewGuid();
const string username = "nonexistent";
const string confirmationToken = "valid-token-for-nonexistent-user";
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(MakeValidatedToken(userId, username));
_authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync((UserAccount?)null);
Func<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle(
new ConfirmUserCommand(confirmationToken),
CancellationToken.None
);
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
}
}

View File

@@ -0,0 +1,34 @@
using Domain.Entities;
using Features.Auth.Commands.RefreshToken;
using Features.Auth.Dtos;
using Features.Auth.Services;
using FluentAssertions;
using Moq;
namespace Features.Auth.Tests.Commands;
public class RefreshTokenHandlerTests
{
[Fact]
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
{
Mock<ITokenService> tokenServiceMock = new();
RefreshTokenHandler handler = new(tokenServiceMock.Object);
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
tokenServiceMock
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
LoginPayload result = await handler.Handle(
new RefreshTokenCommand("old-refresh-token"),
CancellationToken.None
);
result.UserAccountId.Should().Be(userId);
result.Username.Should().Be("testuser");
result.RefreshToken.Should().Be("new-refresh-token");
result.AccessToken.Should().Be("new-access-token");
}
}

View File

@@ -0,0 +1,278 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.RegisterUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using MediatR;
using Moq;
using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands;
public class RegisterUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly RegisterUserHandler _handler;
private readonly Mock<IMediator> _mediatorMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
public RegisterUserHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>();
_mediatorMock = new Mock<IMediator>();
_handler = new RegisterUserHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
}
private static RegisterUserCommand ValidCommand(
string username = "newuser",
string email = "john.doe@example.com"
)
{
return new RegisterUserCommand(
username,
"John",
"Doe",
email,
new DateTime(1990, 1, 1),
"SecurePassword123!"
);
}
[Fact]
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
{
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_password_value";
Guid expectedUserId = Guid.NewGuid();
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
)
)
.ReturnsAsync(
new UserAccount
{
UserAccountId = expectedUserId,
Username = command.Username,
FirstName = command.FirstName,
LastName = command.LastName,
Email = command.Email,
DateOfBirth = command.DateOfBirth,
CreatedAt = DateTime.UtcNow,
}
);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
_tokenServiceMock
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
.Returns("confirmation-token");
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(expectedUserId);
result.Username.Should().Be(command.Username);
result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token");
result.ConfirmationEmailSent.Should().BeTrue();
_mediatorMock.Verify(
x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()),
Times.Once
);
}
[Fact]
public async Task Handle_WithExistingUsername_ThrowsConflictException()
{
RegisterUserCommand command = ValidCommand("existinguser");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Username = "existinguser",
};
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync(existingUser);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None);
await act.Should()
.ThrowAsync<ConflictException>()
.WithMessage("Username or email already exists");
_authRepoMock.Verify(
x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Never
);
}
[Fact]
public async Task Handle_WithExistingEmail_ThrowsConflictException()
{
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Email = "existing@example.com",
};
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None);
await act.Should()
.ThrowAsync<ConflictException>()
.WithMessage("Username or email already exists");
}
[Fact]
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
{
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_secure_password";
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
hashedPassword
)
)
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
await _handler.Handle(command, CancellationToken.None);
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
_authRepoMock.Verify(
x =>
x.RegisterUserAsync(
command.Username,
command.FirstName,
command.LastName,
command.Email,
command.DateOfBirth,
hashedPassword
),
Times.Once
);
}
[Fact]
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
{
RegisterUserCommand command = ValidCommand();
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
)
)
.ReturnsAsync(
new UserAccount
{
UserAccountId = Guid.NewGuid(),
Username = command.Username,
Email = command.Email,
}
);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
_mediatorMock
.Setup(x =>
x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>())
)
.ThrowsAsync(new Exception("smtp down"));
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
result.ConfirmationEmailSent.Should().BeFalse();
result.AccessToken.Should().Be("access-token");
}
}

View File

@@ -0,0 +1,96 @@
using Domain.Entities;
using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Repository;
using Features.Auth.Services;
using MediatR;
using Moq;
using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands;
public class ResendConfirmationEmailHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
public ResendConfirmationEmailHandlerTests()
{
_handler = new ResendConfirmationEmailHandler(
_authRepositoryMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
}
[Fact]
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{
Guid userId = Guid.NewGuid();
UserAccount user = new()
{
UserAccountId = userId,
FirstName = "Aaron",
Email = "aaron@example.com",
};
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user)).Returns("fresh-token");
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify(
x =>
x.Send(
It.Is<SendResendConfirmationEmailCommand>(c =>
c.FirstName == "Aaron"
&& c.Email == "aaron@example.com"
&& c.ConfirmationToken == "fresh-token"
),
It.IsAny<CancellationToken>()
),
Times.Once
);
}
[Fact]
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
{
Guid userId = Guid.NewGuid();
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify(
x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never
);
}
[Fact]
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
{
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
_mediatorMock.Verify(
x =>
x.Send(
It.IsAny<SendResendConfirmationEmailCommand>(),
It.IsAny<CancellationToken>()
),
Times.Never
);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.Auth.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="DbMocker" Version="1.26.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,155 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using Moq;
namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
public LoginHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>();
_handler = new LoginHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object
);
}
[Fact]
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{
const string username = "CogitoErgoSum";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new()
{
UserAccountId = userAccountId,
Username = username,
FirstName = "René",
LastName = "Descartes",
Email = "r.descartes@example.com",
DateOfBirth = new DateTime(1596, 03, 31),
};
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "some-hash",
Expiry = DateTime.MaxValue,
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token");
LoginPayload result = await _handler.Handle(
new LoginQuery(username, "any-password"),
CancellationToken.None
);
result.Should().NotBeNull();
result.UserAccountId.Should().Be(userAccountId);
result.Username.Should().Be(username);
result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token");
}
[Fact]
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
{
const string username = "de_beauvoir";
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(username))
.ReturnsAsync((UserAccount?)null);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
Times.Never
);
}
[Fact]
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{
const string username = "BRussell";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync((UserCredential?)null);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
}
[Fact]
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{
const string username = "RCarnap";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "hashed-password",
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(
new LoginQuery(username, "wrong-password"),
CancellationToken.None
);
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
}
}

View File

@@ -1,23 +1,24 @@
using Apps72.Dev.Data.DbMocker;
using Domain.Entities;
using Features.Auth.Repository;
using FluentAssertions;
using Infrastructure.Repository.Auth;
using Infrastructure.Repository.Tests.Database;
namespace Infrastructure.Repository.Tests.Auth;
namespace Features.Auth.Tests.Repository;
public class AuthRepositoryTest
public class AuthRepositoryTests
{
private static AuthRepository CreateRepo(MockDbConnection conn) =>
new(new TestConnectionFactory(conn));
private static AuthRepository CreateRepo(MockDbConnection conn)
{
return new AuthRepository(new TestConnectionFactory(conn));
}
[Fact]
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
{
var expectedUserId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid expectedUserId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
.ReturnsScalar(expectedUserId);
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
// Mock the subsequent read for the newly created user by id
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
@@ -47,14 +48,14 @@ public class AuthRepositoryTest
)
);
var repo = CreateRepo(conn);
var result = await repo.RegisterUserAsync(
username: "testuser",
firstName: "Test",
lastName: "User",
email: "test@example.com",
dateOfBirth: new DateTime(1990, 1, 1),
passwordHash: "hashedpassword123"
AuthRepository repo = CreateRepo(conn);
UserAccount result = await repo.RegisterUserAsync(
"testuser",
"Test",
"User",
"test@example.com",
new DateTime(1990, 1, 1),
"hashedpassword123"
);
result.Should().NotBeNull();
@@ -69,8 +70,8 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(
@@ -99,8 +100,8 @@ public class AuthRepositoryTest
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
@@ -113,13 +114,13 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
result.Should().BeNull();
}
@@ -127,12 +128,10 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(
MockTable
.WithColumns(
@@ -159,8 +158,8 @@ public class AuthRepositoryTest
)
);
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("usernameuser");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
result.Should().NotBeNull();
result!.UserAccountId.Should().Be(userId);
@@ -171,15 +170,13 @@ public class AuthRepositoryTest
[Fact]
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
{
var conn = new MockDbConnection();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "usp_GetUserAccountByUsername"
)
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetUserByUsernameAsync("nonexistent");
AuthRepository repo = CreateRepo(conn);
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
result.Should().BeNull();
}
@@ -187,13 +184,11 @@ public class AuthRepositoryTest
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
{
var userId = Guid.NewGuid();
var credentialId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
Guid credentialId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(
MockTable
.WithColumns(
@@ -203,17 +198,11 @@ public class AuthRepositoryTest
("CreatedAt", typeof(DateTime)),
("Timer", typeof(byte[]))
)
.AddRow(
credentialId,
userId,
"hashed_password_value",
DateTime.UtcNow,
null
)
.AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
);
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().NotBeNull();
result!.UserCredentialId.Should().Be(credentialId);
@@ -224,16 +213,14 @@ public class AuthRepositoryTest
[Fact]
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
{
var userId = Guid.NewGuid();
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
MockDbConnection conn = new();
conn.Mocks.When(cmd =>
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
)
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
.ReturnsTable(MockTable.Empty());
var repo = CreateRepo(conn);
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
AuthRepository repo = CreateRepo(conn);
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
result.Should().BeNull();
}
@@ -241,18 +228,16 @@ public class AuthRepositoryTest
[Fact]
public async Task RotateCredentialAsync_ExecutesSuccessfully()
{
var userId = Guid.NewGuid();
var newPasswordHash = "new_hashed_password";
var conn = new MockDbConnection();
Guid userId = Guid.NewGuid();
string newPasswordHash = "new_hashed_password";
MockDbConnection conn = new();
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
.ReturnsScalar(1);
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
var repo = CreateRepo(conn);
AuthRepository repo = CreateRepo(conn);
// Should not throw
var act = async () =>
await repo.RotateCredentialAsync(userId, newPasswordHash);
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
await act.Should().NotThrowAsync();
}
}

View File

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

View File

@@ -0,0 +1,173 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
namespace Features.Auth.Tests.Services;
public class TokenServiceRefreshTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceRefreshTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens
Environment.SetEnvironmentVariable(
"ACCESS_TOKEN_SECRET",
"test-access-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
}
[Fact]
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
UserAccount userAccount = new()
{
UserAccountId = userId,
Username = username,
FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
};
// Mock the validation of refresh token
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
// Mock the generation of new tokens
_tokenInfraMock
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
.Returns(
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
);
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
// Act
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
// Assert
result.Should().NotBeNull();
result.UserAccount.UserAccountId.Should().Be(userId);
result.UserAccount.Username.Should().Be(username);
result.AccessToken.Should().NotBeEmpty();
result.RefreshToken.Should().NotBeEmpty();
_authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
// Verify tokens were generated (called twice - once for access, once for refresh)
_tokenInfraMock.Verify(
x =>
x.GenerateJwt(
It.IsAny<Guid>(),
It.IsAny<string>(),
It.IsAny<DateTime>(),
It.IsAny<string>()
),
Times.Exactly(2)
);
}
[Fact]
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string invalidToken = "invalid-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
{
// Arrange
const string expiredToken = "expired-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string refreshToken = "valid-refresh-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*User account not found*");
}
}

View File

@@ -0,0 +1,293 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Exceptions;
using Features.Auth.Repository;
using Features.Auth.Services;
using FluentAssertions;
using Infrastructure.Jwt;
using Moq;
namespace Features.Auth.Tests.Services;
public class TokenServiceValidationTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
public TokenServiceValidationTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
// Set environment variables for tokens
Environment.SetEnvironmentVariable(
"ACCESS_TOKEN_SECRET",
"test-access-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
}
[Fact]
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-access-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
result.Principal.Should().NotBeNull();
result
.Principal.FindFirst(JwtRegisteredClaimNames.Sub)
?.Value.Should()
.Be(userId.ToString());
}
[Fact]
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-refresh-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
[Fact]
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
{
// Arrange
Guid userId = Guid.NewGuid();
const string username = "testuser";
const string token = "valid-confirmation-token";
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act
ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
// Assert
result.Should().NotBeNull();
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
}
[Fact]
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "expired-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Token has expired"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-without-user-id";
// Claims without Sub (user ID)
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
{
// Arrange
Guid userId = Guid.NewGuid();
const string token = "token-without-username";
// Claims without UniqueName (username)
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*missing required claims*");
}
[Fact]
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
{
// Arrange
const string username = "testuser";
const string token = "token-with-malformed-user-id";
// Claims with invalid GUID format
List<Claim> claims = new()
{
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ReturnsAsync(principal);
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("*malformed user ID*");
}
[Fact]
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-refresh-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
[Fact]
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
{
// Arrange
const string token = "invalid-confirmation-token";
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
.ThrowsAsync(new UnauthorizedException("Invalid token"));
// Act & Assert
await FluentActions
.Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
.Should()
.ThrowAsync<UnauthorizedException>();
}
}

View File

@@ -0,0 +1,10 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.ConfirmUser;
/// <summary>
/// Validates a confirmation token and confirms the corresponding user account.
/// </summary>
/// <param name="Token">The confirmation token issued to the user, typically delivered via email.</param>
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;

View File

@@ -0,0 +1,38 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using MediatR;
namespace Features.Auth.Commands.ConfirmUser;
/// <summary>
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed.
/// </summary>
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
/// <param name="tokenService">Service used to validate the confirmation token.</param>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{
/// <exception cref="UnauthorizedException">
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
/// </exception>
public async Task<ConfirmationPayload> Handle(
ConfirmUserCommand request,
CancellationToken cancellationToken
)
{
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(
request.Token
);
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
if (user == null)
throw new UnauthorizedException("User account not found");
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
}
}

View File

@@ -0,0 +1,10 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
/// request body of <c>POST /api/auth/refresh</c>.
/// </summary>
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;

View File

@@ -0,0 +1,28 @@
using Features.Auth.Dtos;
using Features.Auth.Services;
using MediatR;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
/// access/refresh token pair.
/// </summary>
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
public class RefreshTokenHandler(ITokenService tokenService)
: IRequestHandler<RefreshTokenCommand, LoginPayload>
{
public async Task<LoginPayload> Handle(
RefreshTokenCommand request,
CancellationToken cancellationToken
)
{
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(
result.UserAccount.UserAccountId,
result.UserAccount.Username,
result.RefreshToken,
result.AccessToken
);
}
}

View File

@@ -0,0 +1,17 @@
using FluentValidation;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
/// </summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
{
/// <summary>
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
/// </summary>
public RefreshTokenValidator()
{
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
}
}

View File

@@ -0,0 +1,28 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
/// </summary>
/// <param name="Username">
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
/// underscores, and hyphens.
/// </param>
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
/// <param name="LastName">The user's last name; up to 128 characters.</param>
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
/// <param name="Password">
/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a
/// lowercase letter, a number, and a special character.
/// </param>
public record RegisterUserCommand(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string Password
) : IRequest<RegistrationPayload>;

View File

@@ -0,0 +1,102 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR;
using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails.
/// </summary>
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
public class RegisterUserHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService,
IMediator mediator
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
{
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// </exception>
public async Task<RegistrationPayload> Handle(
RegisterUserCommand request,
CancellationToken cancellationToken
)
{
await ValidateUserDoesNotExist(request.Username, request.Email);
string hashed = passwordInfrastructure.Hash(request.Password);
UserAccount createdUser = await authRepo.RegisterUserAsync(
request.Username,
request.FirstName,
request.LastName,
request.Email,
request.DateOfBirth,
hashed
);
string accessToken = tokenService.GenerateAccessToken(createdUser);
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return new RegistrationPayload(
createdUser.UserAccountId,
createdUser.Username,
string.Empty,
string.Empty,
false
);
bool emailSent = false;
try
{
await mediator.Send(
new SendRegistrationEmailCommand(
createdUser.FirstName,
createdUser.Email,
confirmationToken
),
cancellationToken
);
emailSent = true;
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync(ex.Message);
Console.WriteLine("Could not send email.");
// ignored
}
return new RegistrationPayload(
createdUser.UserAccountId,
createdUser.Username,
refreshToken,
accessToken,
emailSent
);
}
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// </exception>
private async Task ValidateUserDoesNotExist(string username, string email)
{
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null)
throw new ConflictException("Username or email already exists");
}
}

View File

@@ -1,20 +1,17 @@
using API.Core.Contracts.Common;
using FluentValidation;
namespace API.Core.Contracts.Auth;
namespace Features.Auth.Commands.RegisterUser;
public record RegisterRequest(
string Username,
string FirstName,
string LastName,
string Email,
DateTime DateOfBirth,
string Password
);
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
/// <summary>
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
/// </summary>
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
{
public RegisterRequestValidator()
/// <summary>
/// Configures validation rules for username format and length, first/last name length, email format and
/// length, minimum age based on date of birth, and password strength requirements.
/// </summary>
public RegisterUserValidator()
{
RuleFor(x => x.Username)
.NotEmpty()
@@ -64,8 +61,6 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
.Matches("[0-9]")
.WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]")
.WithMessage(
"Password must contain at least one special character"
);
.WithMessage("Password must contain at least one special character");
}
}

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary>
/// Resends the account confirmation email to a user, generating a fresh confirmation token.
/// </summary>
/// <param name="UserId">The unique identifier of the user requesting the resend.</param>
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;

View File

@@ -0,0 +1,44 @@
using Domain.Entities;
using Features.Auth.Repository;
using Features.Auth.Services;
using MediatR;
using Shared.Application.Emails;
namespace Features.Auth.Commands.ResendConfirmationEmail;
/// <summary>
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and
/// sending it via Features.Emails.
/// </summary>
/// <param name="authRepository">Repository used to look up the user and check verification status.</param>
/// <param name="tokenService">Service used to generate the confirmation token.</param>
/// <param name="mediator">Used to send the cross-slice command that triggers the email.</param>
/// <remarks>
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
/// or if the user's account is already verified.
/// </remarks>
public class ResendConfirmationEmailHandler(
IAuthRepository authRepository,
ITokenService tokenService,
IMediator mediator
) : IRequestHandler<ResendConfirmationEmailCommand>
{
public async Task Handle(
ResendConfirmationEmailCommand request,
CancellationToken cancellationToken
)
{
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
if (user == null)
return; // Silent return to prevent user enumeration
if (await authRepository.IsUserVerifiedAsync(request.UserId))
return; // Already confirmed, no-op
string confirmationToken = tokenService.GenerateConfirmationToken(user);
await mediator.Send(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken
);
}
}

View File

@@ -0,0 +1,139 @@
using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Commands.RefreshToken;
using Features.Auth.Commands.RegisterUser;
using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Dtos;
using Features.Auth.Queries.Login;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace Features.Auth.Controllers;
/// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// </summary>
/// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
/// actions opt out via <c>[AllowAnonymous]</c> since they are entry points used before a caller holds a token.
/// </remarks>
/// <param name="mediator">Used to dispatch auth commands and queries to their handlers.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Registers a new user account.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>201 Created</c> containing the new user's ID,
/// username, issued refresh/access tokens, and whether a confirmation email was sent.
/// </remarks>
/// <param name="command">The registration details, including username, name, email, date of birth, and password.</param>
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="RegistrationPayload" />.</returns>
[AllowAnonymous]
[HttpPost("register")]
public async Task<ActionResult<ResponseBody<RegistrationPayload>>> Register(
[FromBody] RegisterUserCommand command
)
{
RegistrationPayload payload = await mediator.Send(command);
return Created(
"/",
new ResponseBody<RegistrationPayload>
{
Message = "User registered successfully.",
Payload = payload,
}
);
}
/// <summary>
/// Authenticates a user with a username and password.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and a newly issued refresh/access token pair.
/// </remarks>
/// <param name="query">The login credentials (username and password).</param>
/// <returns>An <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous]
[HttpPost("login")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Login([FromBody] LoginQuery query)
{
LoginPayload payload = await mediator.Send(query);
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Logged in successfully.",
Payload = payload,
}
);
}
/// <summary>
/// Confirms a user account using a confirmation token.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c> containing the confirmed
/// user's ID and the confirmation timestamp.
/// </remarks>
/// <param name="token">The confirmation token supplied via the confirmation email link.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="ConfirmationPayload" />.</returns>
[HttpPost("confirm")]
public async Task<ActionResult<ResponseBody<ConfirmationPayload>>> Confirm(
[FromQuery] string token
)
{
ConfirmationPayload payload = await mediator.Send(new ConfirmUserCommand(token));
return Ok(
new ResponseBody<ConfirmationPayload>
{
Message = "User with ID " + payload.UserAccountId + " is confirmed.",
Payload = payload,
}
);
}
/// <summary>
/// Resends the account confirmation email for the specified user.
/// </summary>
/// <remarks>
/// Requires JWT authentication. On success, responds with <c>200 OK</c>.
/// </remarks>
/// <param name="userId">The unique identifier of the user account to resend the confirmation email for.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody" /> confirming the email was resent.</returns>
[HttpPost("confirm/resend")]
public async Task<ActionResult<ResponseBody>> ResendConfirmation([FromQuery] Guid userId)
{
await mediator.Send(new ResendConfirmationEmailCommand(userId));
return Ok(new ResponseBody { Message = "confirmation email has been resent" });
}
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair.
/// </summary>
/// <remarks>
/// Allows anonymous access. On success, responds with <c>200 OK</c> containing the user's ID,
/// username, and the newly issued refresh/access token pair.
/// </remarks>
/// <param name="command">The request containing the refresh token to exchange.</param>
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> of <see cref="LoginPayload" />.</returns>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
[FromBody] RefreshTokenCommand command
)
{
LoginPayload payload = await mediator.Send(command);
return Ok(
new ResponseBody<LoginPayload>
{
Message = "Token refreshed successfully.",
Payload = payload,
}
);
}
}

View File

@@ -0,0 +1,20 @@
using Features.Auth.Repository;
using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using Microsoft.Extensions.DependencyInjection;
namespace Features.Auth.DependencyInjection;
/// <summary>
/// Registers the services owned by the Auth feature slice.
/// </summary>
public static class FeaturesAuthServiceCollectionExtensions
{
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
{
services.AddScoped<IAuthRepository, AuthRepository>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
return services;
}
}

View File

@@ -0,0 +1,38 @@
namespace Features.Auth.Dtos;
/// <summary>
/// Payload returned to the client after a successful login or token refresh.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the authenticated user account.</param>
/// <param name="Username">The username of the authenticated user account.</param>
/// <param name="RefreshToken">The newly issued refresh token used to obtain new access tokens.</param>
/// <param name="AccessToken">The newly issued JWT access token used to authorize subsequent requests.</param>
public record LoginPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken
);
/// <summary>
/// Payload returned to the client after a successful registration.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the newly created user account.</param>
/// <param name="Username">The username of the newly created user account.</param>
/// <param name="RefreshToken">The refresh token issued for the new account.</param>
/// <param name="AccessToken">The JWT access token issued for the new account.</param>
/// <param name="ConfirmationEmailSent">Whether a confirmation email was successfully sent to the new user.</param>
public record RegistrationPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
string AccessToken,
bool ConfirmationEmailSent
);
/// <summary>
/// Payload returned to the client after a user account's email has been confirmed.
/// </summary>
/// <param name="UserAccountId">The unique identifier of the user account that was confirmed.</param>
/// <param name="ConfirmedDate">The date and time at which the account was confirmed.</param>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.Auth</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,49 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR;
namespace Features.Auth.Queries.Login;
/// <summary>
/// Handles <see cref="LoginQuery" /> by verifying credentials and issuing access/refresh tokens.
/// </summary>
/// <param name="authRepo">Repository used to look up the user account and its active credential.</param>
/// <param name="passwordInfrastructure">
/// Infrastructure component used to verify a plain-text password against a stored
/// hash.
/// </param>
/// <param name="tokenService">Service used to generate access and refresh tokens for the authenticated user.</param>
public class LoginHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService
) : IRequestHandler<LoginQuery, LoginPayload>
{
/// <exception cref="UnauthorizedException">
/// Thrown when the username does not match any account, the account has no active credential,
/// or the supplied password does not match the stored hash.
/// </exception>
public async Task<LoginPayload> Handle(LoginQuery request, CancellationToken cancellationToken)
{
UserAccount user =
await authRepo.GetUserByUsernameAsync(request.Username)
?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords
UserCredential activeCred =
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
throw new UnauthorizedException("Invalid username or password.");
string accessToken = tokenService.GenerateAccessToken(user);
string refreshToken = tokenService.GenerateRefreshToken(user);
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
}
}

View File

@@ -0,0 +1,10 @@
using Features.Auth.Dtos;
using MediatR;
namespace Features.Auth.Queries.Login;
/// <summary>
/// Authenticates a user using their username and password and issues new tokens. Bound directly
/// from the request body of <c>POST /api/auth/login</c>.
/// </summary>
public record LoginQuery(string Username, string Password) : IRequest<LoginPayload>;

View File

@@ -0,0 +1,20 @@
using FluentValidation;
namespace Features.Auth.Queries.Login;
/// <summary>
/// Validates <see cref="LoginQuery" /> instances before they are processed.
/// </summary>
public class LoginValidator : AbstractValidator<LoginQuery>
{
/// <summary>
/// Configures validation rules requiring both <see cref="LoginQuery.Username" /> and
/// <see cref="LoginQuery.Password" /> to be non-empty.
/// </summary>
public LoginValidator()
{
RuleFor(x => x.Username).NotEmpty().WithMessage("Username is required");
RuleFor(x => x.Password).NotEmpty().WithMessage("Password is required");
}
}

View File

@@ -0,0 +1,337 @@
using System.Data;
using System.Data.Common;
using Domain.Entities;
using Infrastructure.Sql;
using Microsoft.Data.SqlClient;
namespace Features.Auth.Repository;
/// <summary>
/// ADO.NET-based implementation of <see cref="IAuthRepository" /> backed by SQL Server stored procedures,
/// handling user registration, credential lookup/rotation, and account verification.
/// </summary>
/// <param name="connectionFactory">The factory used to create database connections.</param>
public class AuthRepository(ISqlConnectionFactory connectionFactory)
: Repository<UserAccount>(connectionFactory),
IAuthRepository
{
/// <summary>
/// Registers a new user account and initial credential using the <c>USP_RegisterUser</c> stored
/// procedure, then fetches and returns the newly created user.
/// </summary>
/// <remarks>
/// The stored procedure's scalar result (expected to be the new user's ID) is parsed defensively:
/// it may be returned as a <see cref="Guid" />, a parseable <see cref="string" />, or a 16-byte array.
/// If the result cannot be interpreted, <see cref="Guid.Empty" /> is used, which will cause the
/// subsequent lookup to fail.
/// </remarks>
/// <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>
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount> RegisterUserAsync(
string username,
string firstName,
string lastName,
string email,
DateTime dateOfBirth,
string passwordHash
)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand 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);
object? result = await command.ExecuteScalarAsync();
Guid userAccountId = Guid.Empty;
if (result != null && result != DBNull.Value)
{
if (result is Guid g)
userAccountId = g;
else if (result is string s && Guid.TryParse(s, out Guid parsed))
userAccountId = parsed;
else if (result is byte[] bytes && bytes.Length == 16)
userAccountId = new Guid(bytes);
else
// Fallback: try to convert and parse string representation
try
{
string? str = result.ToString();
if (!string.IsNullOrEmpty(str) && Guid.TryParse(str, out Guid p))
userAccountId = p;
}
catch
{
userAccountId = Guid.Empty;
}
}
return await GetUserByIdAsync(userAccountId)
?? throw new Exception("Failed to retrieve newly registered user.");
}
/// <summary>
/// Retrieves a user account by email address (typically used for login) using the
/// <c>usp_GetUserAccountByEmail</c> stored procedure.
/// </summary>
/// <param name="email">Email address to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByEmailAsync(string email)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByEmail";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Email", email);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
/// <summary>
/// Retrieves a user account by username (typically used for login) using the
/// <c>usp_GetUserAccountByUsername</c> stored procedure.
/// </summary>
/// <param name="username">Username to search for</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountByUsername";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@Username", username);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
/// <summary>
/// Retrieves the active (non-revoked) credential for a user account using the
/// <c>USP_GetActiveUserCredentialByUserAccountId</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>Active UserCredential if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_GetActiveUserCredentialByUserAccountId";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
}
/// <summary>
/// Rotates a user's credential by invalidating all existing credentials and creating a new one,
/// using the <c>USP_RotateUserCredential</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <param name="newPasswordHash">New hashed password</param>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_RotateUserCredential";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId_", userAccountId);
AddParameter(command, "@Hash", newPasswordHash);
await command.ExecuteNonQueryAsync();
}
/// <summary>
/// Retrieves a user account by ID using the <c>usp_GetUserAccountById</c> stored procedure.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>UserAccount if found, null otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "usp_GetUserAccountById";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountId", userAccountId);
await using DbDataReader reader = await command.ExecuteReaderAsync();
return await reader.ReadAsync() ? MapToEntity(reader) : null;
}
/// <summary>
/// Marks a user account as confirmed by creating a verification record via the
/// <c>USP_CreateUserVerification</c> stored procedure. If the user is already verified, this is a
/// no-op and the existing user is returned (idempotent). If a concurrent request verifies the user
/// first, the resulting duplicate-key SQL exception (error 2601/2627) is swallowed.
/// </summary>
/// <param name="userAccountId">ID of the user account to confirm</param>
/// <returns>The confirmed <see cref="Domain.Entities.UserAccount" />, or <c>null</c> if the user account does not exist.</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
/// Thrown when the database command fails for a reason other than
/// a duplicate verification record.
/// </exception>
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
{
UserAccount? user = await GetUserByIdAsync(userAccountId);
if (user == null)
return null;
// Idempotency: if already verified, treat as successful confirmation.
if (await IsUserVerifiedAsync(userAccountId))
return user;
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText = "USP_CreateUserVerification";
command.CommandType = CommandType.StoredProcedure;
AddParameter(command, "@UserAccountID_", userAccountId);
try
{
await command.ExecuteNonQueryAsync();
}
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
{
// A concurrent request verified this user first. Keep behavior idempotent.
}
// Fetch and return the updated user
return await GetUserByIdAsync(userAccountId);
}
/// <summary>
/// Checks whether a user account has been verified by querying the
/// <c>dbo.UserVerification</c> table for a matching record.
/// </summary>
/// <param name="userAccountId">ID of the user account</param>
/// <returns>True if the user has a verification record, false otherwise</returns>
/// <exception cref="Microsoft.Data.SqlClient.SqlException">Thrown when the database command fails.</exception>
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
{
await using DbConnection connection = await CreateConnection();
await using DbCommand command = connection.CreateCommand();
command.CommandText =
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
command.CommandType = CommandType.Text;
AddParameter(command, "@UserAccountID", userAccountId);
object? result = await command.ExecuteScalarAsync();
return result != null && result != DBNull.Value;
}
/// <summary>
/// Determines whether a <see cref="SqlException" /> represents a duplicate key violation
/// (SQL Server error 2601 or 2627), used to detect a concurrent duplicate verification insert.
/// </summary>
/// <param name="ex">The SQL exception to inspect.</param>
/// <returns>True if the exception represents a duplicate key violation, false otherwise.</returns>
private static bool IsDuplicateVerificationViolation(SqlException ex)
{
// 2601/2627 are duplicate key violations in SQL Server.
return ex.Number == 2601 || ex.Number == 2627;
}
/// <summary>
/// Maps a data reader row to a UserAccount entity.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="Domain.Entities.UserAccount" /> instance.</returns>
protected override UserAccount MapToEntity(DbDataReader reader)
{
return new 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. The <c>Timer</c> column is mapped only if
/// present in the reader's schema, allowing this method to support result sets that omit it.
/// </summary>
/// <param name="reader">The data reader positioned on the row to map.</param>
/// <returns>The mapped <see cref="UserCredential" /> instance.</returns>
private static UserCredential MapToCredentialEntity(DbDataReader reader)
{
UserCredential entity = new()
{
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
bool hasTimer =
reader
.GetSchemaTable()
?.Rows.Cast<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, converting <c>null</c> values to
/// <see cref="DBNull.Value" />.
/// </summary>
/// <param name="command">The command to add the parameter to.</param>
/// <param name="name">The parameter name (including any prefix, e.g. "@Username").</param>
/// <param name="value">The parameter value, or <c>null</c> to bind <see cref="DBNull.Value" />.</param>
private static void AddParameter(DbCommand command, string name, object? value)
{
DbParameter p = command.CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
command.Parameters.Add(p);
}
}

Some files were not shown because too many files have changed in this diff Show More