32 Commits

Author SHA1 Message Date
Aaron Po
3711591db1 commit csharpier config update 2026-06-20 15:54:53 -04:00
Aaron Po
194509f9c9 update mailkit 2026-06-20 15:54:53 -04:00
Aaron Po
67d6350425 Format ./web/backend/Shared/Shared.Contracts 2026-06-20 15:54:53 -04:00
Aaron Po
cee2917556 Format ./web/backend/Shared/Shared.Application 2026-06-20 15:54:53 -04:00
Aaron Po
6f8e5dc722 Format ./web/backend/API/API.Specs 2026-06-20 15:54:53 -04:00
Aaron Po
db3ed3581d Format ./web/backend/API/API.Core 2026-06-20 15:54:53 -04:00
Aaron Po
b54f70ccbe Format ./web/backend/Domain/Domain.Exceptions 2026-06-20 15:54:53 -04:00
Aaron Po
d4734ae9e7 Format ./web/backend/Domain/Domain.Entities 2026-06-20 15:54:53 -04:00
Aaron Po
27f28d5207 Format ./web/backend/Database/Database.Seed 2026-06-20 15:54:53 -04:00
Aaron Po
bf0d29fa54 Format ./web/backend/Database/Database.Migrations 2026-06-20 15:54:53 -04:00
Aaron Po
4772f59a68 Format ./web/backend/Infrastructure/Infrastructure.Sql 2026-06-20 15:54:53 -04:00
Aaron Po
2e5d4923c0 Format ./web/backend/Infrastructure/Infrastructure.PasswordHashing 2026-06-20 15:54:53 -04:00
Aaron Po
62766b7638 Format ./web/backend/Infrastructure/Infrastructure.Jwt 2026-06-20 15:54:53 -04:00
Aaron Po
744d4c7f8c Format ./web/backend/Infrastructure/Infrastructure.Email.Templates 2026-06-20 15:54:53 -04:00
Aaron Po
2f28ac9350 Format ./web/backend/Infrastructure/Infrastructure.Email 2026-06-20 15:54:53 -04:00
Aaron Po
c8bddae817 Format ./web/backend/Features/Features.UserManagement.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
b298bd02d3 Format ./web/backend/Features/Features.UserManagement 2026-06-20 15:54:53 -04:00
Aaron Po
e2e5eb89e3 Format ./web/backend/Features/Features.Emails.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
3dd44fca5a Format ./web/backend/Features/Features.Emails 2026-06-20 15:54:53 -04:00
Aaron Po
0c55d6c83f Format ./web/backend/Features/Features.Breweries.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
58cbc5b5ba Format ./web/backend/Features/Features.Breweries 2026-06-20 15:54:53 -04:00
Aaron Po
5bef24696b Format ./web/backend/Features/Features.Auth.Tests 2026-06-20 15:54:53 -04:00
Aaron Po
79ae18b3e2 Format ./web/backend/Features/Features.Auth 2026-06-20 15:54:53 -04:00
Aaron Po
254431928f code style cleanup 2026-06-20 15:54:53 -04:00
Aaron Po
07aedcb866 Update documentation to reflect new architecture and previous refactors 2026-06-20 15:54:53 -04:00
Aaron Po
fd341e332c 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 15:54:53 -04:00
Aaron Po
5cf4df1fd8 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 15:54:53 -04:00
Aaron Po
34ba7e8271 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 15:54:53 -04:00
Aaron Po
60a7b790d4 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 15:54:53 -04:00
Aaron Po
4554fff534 add tests 2026-06-20 15:54:53 -04:00
Aaron Po
86a0c50f6e finish brewery feature set 2026-06-20 15:54:53 -04:00
Aaron Po
3034020d56 add xmldoc comments 2026-06-20 15:54:53 -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: 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) - React 19 website (React Router 7 + Vite)
- Shared Biergarten theme system + Storybook coverage - Shared Biergarten theme system + Storybook coverage
- Auth flows and account/email integration (local Mailpit in dev compose) - Auth flows and account/email integration (local Mailpit in dev compose)
@@ -105,7 +105,8 @@ npm run test:storybook:playwright
```text ```text
web/ 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 frontend/ React Router website + Storybook + Playwright/Vitest
tooling/ 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 The Biergarten App is a monorepo with a clear split between the backend and the
active website: active website:
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture - **Backend**: .NET 10 Web API with SQL Server, organized as vertical feature
- **Frontend**: React 19 + React Router 7 website in `src/Website` slices with MediatR
- **Architecture Style**: Layered backend plus server-rendered React frontend - **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 The legacy Next.js frontend has been retained in `archive/next-js-web-app/`
only and is documented in for reference only.
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Diagrams ## Diagrams
For visual representations, see: 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 diagram
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram - [authentication-flow.svg](website/diagrams-out/authentication-flow.svg) -
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) -
Authentication workflow Authentication workflow
- [database-schema.svg](diagrams-out/database-schema.svg) - Database - [database-schema.svg](website/diagrams-out/database-schema.svg) - Database
relationships relationships
## Backend Architecture ## 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) API.Core (thin host)
│ - HTTP Endpoints │ - Program.cs wiring: MediatR + AddApplicationPart per slice
│ - Request/Response mapping │ - Swagger/OpenAPI, JWT auth middleware, global exception filter
│ - Swagger/OpenAPI │ └───────────────────────────────────────────────────────────────────────┘
└─────────────────────────────────────┘ ↓ 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) Infrastructure.Sql, Infrastructure.Jwt, Infrastructure.PasswordHashing,
- Authentication logic Infrastructure.Email, Infrastructure.Email.Templates
│ - 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 │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ Database (SQL Server) │ │ 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 ### Layer Responsibilities
#### API Layer (`API.Core`) #### 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**: **Components**:
- Controllers (`AuthController`, `UserController`) - `Program.cs`: registers MediatR (scanning every `Features.*` assembly),
- Middleware for error handling FluentValidation, and uses `AddApplicationPart` so each slice's controllers
- Swagger/OpenAPI documentation are discovered by MVC
- Health check endpoints - `GlobalException.cs`: global exception filter (host-level cross-cutting
concern)
- `Authentication/JwtAuthenticationHandler.cs`: JWT auth scheme middleware
- Swagger/OpenAPI documentation, health check endpoints
**Dependencies**: **Dependencies**:
- Service layer - Every `Features.*` project (for controller/MediatR discovery)
- ASP.NET Core framework - `Shared.Contracts`, `Shared.Application`
- `Infrastructure.Jwt` (for the auth middleware)
**Rules**: **Rules**:
- No business logic - No controllers, no business logic, no feature-specific contracts
- Only request/response transformation - Exists purely to host and wire up the feature slices
- Delegates all work to Service layer
#### 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**: **Components**:
- Authentication services (login, registration) - `Shared.Contracts`: `ResponseBody<T>`/`ResponseBody`, the API response
- User management services envelope every controller returns
- Business rule validation - `Shared.Application`: `ValidationBehavior<TRequest,TResponse>` (the
- Transaction coordination MediatR pipeline behavior that runs FluentValidation before a handler
executes) and the cross-slice email commands
**Dependencies**: (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
- Infrastructure layer (repositories, JWT, password hashing)
- Domain entities
**Rules**: **Rules**:
- Contains all business logic - Kept deliberately small: this is the exception to "no slice depends on
- Coordinates multiple infrastructure components another slice," not a general-purpose dumping ground
- No direct database access (uses repositories)
- Returns domain models, not DTOs
#### Infrastructure Layer #### Infrastructure Layer
**Purpose**: Technical capabilities and external integrations **Purpose**: Technical capabilities and external integrations, shared by
whichever slices need them
**Components**: **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.Jwt**: JWT token generation and validation
- **Infrastructure.PasswordHashing**: Argon2id password hashing - **Infrastructure.PasswordHashing**: Argon2id password hashing
- **Infrastructure.Email**: Email sending capabilities - **Infrastructure.Email**: Email sending capabilities (SMTP/MailKit)
- **Infrastructure.Email.Templates**: Email template rendering - **Infrastructure.Email.Templates**: Email template rendering (Razor
components)
**Dependencies**: **Dependencies**:
- Domain entities - Domain entities
- External libraries (ADO.NET, JWT, Argon2, etc.) - External libraries (ADO.NET, JWT, Argon2, MailKit, etc.)
**Rules**: **Rules**:
- Implements technical concerns - Implements technical concerns only, no business logic
- No business logic - Reusable across slices
- Reusable across services
#### Domain Layer (`Domain.Entities`) #### Domain Layer (`Domain.Entities`)
@@ -163,21 +215,55 @@ The backend follows a strict layered architecture:
### Design Patterns ### 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 #### Repository Pattern
**Purpose**: Abstract database access behind interfaces **Purpose**: Abstract database access behind interfaces
**Implementation**: **Implementation**: each slice owns its own repository, scoped to that
feature only:
- `IAuthRepository` - Authentication queries - `Features.Auth/Repository/IAuthRepository.cs`
- `IUserAccountRepository` - User account queries - `Features.Breweries/Repository/IBreweryRepository.cs`
- `DefaultSqlConnectionFactory` - Connection management - `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Infrastructure.Sql/DefaultSqlConnectionFactory.cs`: the generic
connection factory every slice's repository builds on
**Benefits**: **Benefits**:
- Testable (easy to mock) - Testable (easy to mock)
- SQL-first approach (stored procedures) - SQL-first approach (stored procedures)
- Centralized data access logic - Each slice's data access logic is self-contained
**Example**: **Example**:
@@ -193,17 +279,19 @@ public interface IAuthRepository
**Purpose**: Loose coupling and testability **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**: **Lifetimes**:
- Scoped: Repositories, Services (per request) - Scoped: Repositories, slice-internal services (per request)
- Singleton: Connection factories, JWT configuration - Singleton: `ISqlConnectionFactory`
- Transient: Utilities, helpers - Transient: Utilities, helpers
#### SQL-First Approach #### SQL-First Approach
**Purpose**: Leverage database capabilities **Purpose**: Push complex logic into the database
**Strategy**: **Strategy**:
@@ -220,13 +308,13 @@ public interface IAuthRepository
## Frontend Architecture ## Frontend Architecture
### Active Website (`src/Website`) ### Active Website (`web/frontend`)
The current website is a React Router 7 application with server-side rendering The current website is a React Router 7 application with server-side rendering
enabled. enabled.
```text ```text
src/Website/ web/frontend/
├── app/ ├── app/
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider │ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
│ ├── lib/ Auth helpers, schemas, and theme metadata │ ├── lib/ Auth helpers, schemas, and theme metadata
@@ -262,10 +350,9 @@ All component styling should prefer semantic tokens such as `primary`,
### Legacy Frontend ### Legacy Frontend
The previous Next.js frontend has been archived at `src/Website-v1`. Active The previous Next.js frontend has been archived at `archive/next-js-web-app/`
product and engineering documentation should point to `src/Website`, while for reference only. Active product and engineering documentation should point
legacy notes live in to `web/frontend`.
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Security Architecture ## Security Architecture
@@ -390,7 +477,7 @@ scripts/
- Testing (`docker-compose.test.yaml`) - Testing (`docker-compose.test.yaml`)
- Production (`docker-compose.prod.yaml`) - Production (`docker-compose.prod.yaml`)
For details, see [Docker Guide](docker.md). For details, see [Docker Guide](website/docker.md).
### Health Checks ### Health Checks
@@ -416,11 +503,10 @@ healthcheck:
│ Integration │ ← API.Specs (Reqnroll) │ Integration │ ← API.Specs (Reqnroll)
│ Tests │ │ Tests │
├──────────────┤ ├──────────────┤
│ Unit Tests │ ← Service.Auth.Tests │ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests,
(Service)Repository.Tests (per slice,Features.UserManagement.Tests, Features.Emails.Tests
├──────────────┤ │ handlers + │ (commands/queries/handlers + that slice's own
Unit Tests │ repository) │ repository, mocked with Moq/DbMocker)
│ (Repository) │
└──────────────┘ └──────────────┘
``` ```
@@ -431,4 +517,4 @@ healthcheck:
- Mock external dependencies - Mock external dependencies
- Test database for integration tests - 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 defaultFontName Arial
skinparam packageStyle rectangle skinparam packageStyle rectangle
title The Biergarten App - Layered Architecture title The Biergarten App - Vertical Slice Architecture
package "API Layer" #E3F2FD { package "API.Core (thin host)" #E3F2FD {
[API.Core\nASP.NET Core Web API] as API [API.Core\nASP.NET Core Web API host] as API
note right of API note right of API
- Controllers (Auth, User) - Program.cs wiring only
- Swagger/OpenAPI - MediatR + AddApplicationPart
- Middleware per Features.* assembly
- Health Checks - Swagger/OpenAPI, health checks
- JWT auth middleware
- Global exception filter
end note end note
} }
package "Service Layer" #F3E5F5 { package "Feature Slices" #F3E5F5 {
[Service.Auth] as AuthSvc [Features.Auth] as AuthSlice
[Service.UserManagement] as UserSvc [Features.Breweries] as BrewerySlice
note right of AuthSvc [Features.UserManagement] as UserSlice
- Business Logic [Features.Emails] as EmailsSlice
- Validation note right of AuthSlice
- Orchestration 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 end note
} }
package "Infrastructure Layer" #FFF3E0 { package "Infrastructure Layer" #FFF3E0 {
[Infrastructure.Repository] as Repo [Infrastructure.Sql] as Sql
[Infrastructure.Jwt] as JWT [Infrastructure.Jwt] as JWT
[Infrastructure.PasswordHashing] as PwdHash [Infrastructure.PasswordHashing] as PwdHash
[Infrastructure.Email] as Email [Infrastructure.Email] as Email
[Infrastructure.Email.Templates] as EmailTemplates
} }
package "Domain Layer" #E8F5E9 { package "Domain Layer" #E8F5E9 {
[Domain.Entities] as Domain [Domain.Entities] as Domain
[Domain.Exceptions] as DomainExceptions
note right of Domain note right of Domain
- UserAccount - UserAccount
- UserCredential - UserCredential
- UserVerification - UserVerification
- BreweryPost
end note end note
} }
@@ -48,28 +74,53 @@ database "SQL Server" {
} }
' Relationships ' Relationships
API --> AuthSvc API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
API --> UserSvc API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
API ..> EmailsSlice : MediatR scan only\n(no controller)
AuthSvc --> Repo AuthSlice --> Sql
AuthSvc --> JWT AuthSlice --> JWT
AuthSvc --> PwdHash AuthSlice --> PwdHash
AuthSvc --> Email AuthSlice --> SharedContracts
AuthSlice --> SharedApp
UserSvc --> Repo BrewerySlice --> Sql
BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp
Repo --> SP UserSlice --> Sql
Repo --> Domain 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 SP --> Tables
AuthSvc --> Domain AuthSlice --> Domain
UserSvc --> Domain BrewerySlice --> Domain
UserSlice --> Domain
AuthSlice --> DomainExceptions
' Notes ' Notes
note left of Repo note left of Sql
SQL-first approach SQL-first approach
All queries via All queries via
stored procedures stored procedures
end note end note
note bottom of AuthSlice
No Features.* project
ever references another
Features.* project directly
end note
@enduml @enduml

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ The application uses environment variables for:
Direct environment variable access via `Environment.GetEnvironmentVariable()`. 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 The active website reads runtime values from the server environment for its auth
and API integration. 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 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 The active website does not use the old Next.js/Prisma environment model. Its
core runtime variables are: core runtime variables are:
@@ -147,7 +147,7 @@ NODE_ENV=development # Standard Node runtime mode
- **Required**: Yes for local development - **Required**: Yes for local development
- **Default in code**: `http://localhost:8080` - **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 - **Purpose**: Routes website auth actions to the .NET API
#### `SESSION_SECRET` #### `SESSION_SECRET`
@@ -163,15 +163,27 @@ NODE_ENV=development # Standard Node runtime mode
- **Typical values**: `development`, `production`, `test` - **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode - **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 ```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) - **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on
- **Purpose**: Sets admin account password during database seeding startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Security**: Use strong password, change immediately in production - **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 ## Docker-Specific Variables
@@ -191,34 +203,33 @@ MSSQL_PID=Express # SQL Server edition (Express, Developer, Ente
## Environment File Structure ## Environment File Structure
### Backend/Docker (Root Directory) ### Backend/Docker (`web/` Directory)
``` ```
.env.example # Template (tracked in Git) web/.env.example # Template (tracked in Git)
.env.dev # Development config (gitignored) web/.env.dev # Development config (gitignored)
.env.test # Testing config (gitignored) web/.env.test # Testing config (gitignored)
.env.prod # Production config (gitignored) web/.env.prod # Production config (gitignored)
``` ```
**Setup**: **Setup**:
```bash ```bash
cp .env.example .env.dev cp web/.env.example web/.env.dev
# Edit .env.dev with your values # Edit web/.env.dev with your values
``` ```
## Legacy Frontend Variables ## Legacy Frontend Variables
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed Variables for the archived Next.js frontend (`archive/next-js-web-app/`) have
from this active reference. See been removed from this active reference, since that app is retained for
[archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you need the reference only and is not run as part of the active stack.
legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
**Docker Compose Mapping**: **Docker Compose Mapping**:
- `docker-compose.dev.yaml``.env.dev` - `web/docker-compose.dev.yaml``web/.env.dev`
- `docker-compose.test.yaml``.env.test` - `web/docker-compose.test.yaml``web/.env.test`
- `docker-compose.prod.yaml``.env.prod` - `web/docker-compose.prod.yaml``web/.env.prod`
## Variable Reference Table ## Variable Reference Table
@@ -234,6 +245,13 @@ legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing | | `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing | | `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails | | `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 | | `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing | | `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode | | `NODE_ENV` | | ✓ | | No | Runtime mode |
@@ -254,9 +272,12 @@ legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
Variables are validated at startup: Variables are validated at startup:
- Missing required variables cause application to fail - Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
- JWT_SECRET length is enforced (min 32 chars) `CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`, DB connection
- Connection string format is validated 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 ### Frontend Validation
@@ -284,6 +305,13 @@ REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl> CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000 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 # Migration
CLEAR_DATABASE=true CLEAR_DATABASE=true

View File

@@ -1,7 +1,7 @@
# Getting Started # Getting Started
This guide covers local setup for the current Biergarten stack: the .NET backend 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 ## Prerequisites
@@ -22,7 +22,7 @@ cd the-biergarten-app
### 2. Configure Backend Environment Variables ### 2. Configure Backend Environment Variables
```bash ```bash
cp .env.example .env.dev cp web/.env.example web/.env.dev
``` ```
At minimum, ensure `.env.dev` includes valid database and token values: 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 ### 3. Start the Backend Stack
```bash ```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: Available endpoints:
- API Swagger: http://localhost:8080/swagger - API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health - Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
### 4. Start the Active Frontend ### 4. Start the Active Frontend
```bash ```bash
cd src/Website cd web/frontend
npm install npm install
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev 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 ### 5. Optional: Run Storybook
```bash ```bash
cd src/Website cd web/frontend
npm run storybook npm run storybook
``` ```
@@ -82,15 +83,15 @@ Storybook runs at http://localhost:6006 by default.
### Backend ### Backend
```bash ```bash
docker compose -f docker-compose.dev.yaml logs -f docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f
docker compose -f docker-compose.dev.yaml down docker compose --env-file web/.env.dev -f web/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 down -v
``` ```
### Frontend ### Frontend
```bash ```bash
cd src/Website cd web/frontend
npm run lint npm run lint
npm run typecheck npm run typecheck
npm run format:check npm run format:check
@@ -115,7 +116,7 @@ export WEBSITE_BASE_URL="http://localhost:3000"
### 2. Run Migrations and Seed ### 2. Run Migrations and Seed
```bash ```bash
cd src/Core cd web/backend
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
dotnet run --project Database/Database.Seed/Database.Seed.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 ## Legacy Frontend Note
The previous Next.js frontend now lives in `src/Website-v1` and is not the The previous Next.js frontend lives in `archive/next-js-web-app/` for reference
active website. Legacy setup details have been moved to only and is not the active website.
[docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
## Next Steps ## Next Steps
- Review [Architecture](architecture.md) - Review [Architecture](../architecture.md)
- Run backend and frontend checks from [Testing](testing.md) - Run backend and frontend checks from [Testing](testing.md)
- Use [Docker Guide](docker.md) for container troubleshooting - 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: The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin) - **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against
- **Infrastructure.Repository.Tests** - Unit tests for data access layer a live, seeded database
- **Service.Auth.Tests** - Unit tests for authentication business logic - **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 - **Storybook Vitest project** - Browser-based interaction tests for shared
website stories website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered - **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: isolated test environment:
```bash ```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: This command:
@@ -41,15 +45,17 @@ ls -la test-results/
# View specific test results # View specific test results
cat test-results/api-specs/results.trx cat test-results/api-specs/results.trx
cat test-results/repository-tests/results.trx cat test-results/Features.Auth.Tests.trx
cat test-results/service-auth-tests/results.trx cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx
``` ```
### Clean Up ### Clean Up
```bash ```bash
# Remove test containers and volumes # 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 ## Running Tests Locally
@@ -59,7 +65,7 @@ You can run individual test projects locally without Docker:
### Integration Tests (API.Specs) ### Integration Tests (API.Specs)
```bash ```bash
cd src/Core cd web/backend
dotnet test API/API.Specs/API.Specs.csproj dotnet test API/API.Specs/API.Specs.csproj
``` ```
@@ -69,32 +75,31 @@ dotnet test API/API.Specs/API.Specs.csproj
- Database migrated and seeded - Database migrated and seeded
- Environment variables set (DB connection, JWT secret) - 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 ```bash
cd src/Core cd web/backend
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj 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**: **Requirements**:
- SQL Server instance running (uses mock data) - No database required (handlers use Moq; repository tests use DbMocker to
simulate SQL Server responses)
### Service Tests
```bash
cd src/Core
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
```
**Requirements**:
- No database required (uses Moq for mocking)
### Frontend Storybook Tests ### Frontend Storybook Tests
```bash ```bash
cd src/Website cd web/frontend
npm install npm install
npm run test:storybook npm run test:storybook
``` ```
@@ -108,7 +113,7 @@ npm run test:storybook
### Frontend Playwright Storybook Tests ### Frontend Playwright Storybook Tests
```bash ```bash
cd src/Website cd web/frontend
npm install npm install
npm run test:storybook:playwright npm run test:storybook:playwright
``` ```
@@ -124,27 +129,28 @@ npm run test:storybook:playwright
### Current Coverage ### Current Coverage
**Authentication & User Management**: **Features.Auth.Tests**:
- User registration with validation - User registration with validation
- User login with JWT token generation - User login with JWT token generation
- Password hashing and verification (Argon2id) - Password hashing and verification (Argon2id)
- JWT token generation and claims - Email confirmation and confirmation-email resend
- Invalid credentials handling - Refresh token exchange
- 404 error responses - 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 - Brewery create/update/delete commands and get-by-id/get-all queries
- User credential management
- GetUserByUsername queries
- Stored procedure execution
**Service Layer**: **Features.UserManagement.Tests**:
- Login service with password verification - User get-by-id/get-all queries and the (currently unrouted) update command
- Register service with validation
- Business logic for authentication flow **Features.Emails.Tests**:
- Registration and resend-confirmation email dispatch handlers
**Frontend UI Coverage**: **Frontend UI Coverage**:
@@ -156,10 +162,7 @@ npm run test:storybook:playwright
### Planned Coverage ### Planned Coverage
- [ ] Email verification workflow
- [ ] Password reset functionality - [ ] Password reset functionality
- [ ] Token refresh mechanism
- [ ] Brewery data management
- [ ] Beer post operations - [ ] Beer post operations
- [ ] User follow/unfollow - [ ] User follow/unfollow
- [ ] Image upload service - [ ] Image upload service
@@ -204,21 +207,28 @@ npm run test:storybook:playwright
``` ```
API.Specs/ API.Specs/
├── Features/ ├── Features/
│ ├── Authentication.feature # Login/register scenarios │ ├── Registration.feature # Registration scenarios
── UserManagement.feature # User CRUD 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/ ├── Steps/
│ ├── AuthenticationSteps.cs # Step definitions │ ├── AuthSteps.cs # Step definitions for the Auth features
│ └── UserManagementSteps.cs │ └── ApiGeneralSteps.cs # Shared/general step definitions
── Mocks/ ── Mocks/
── TestApiFactory.cs # Test server setup ── 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**: **Example Feature**:
```gherkin ```gherkin
Feature: User Authentication Feature: User Registration
As a user As a user
I want to register and login I want to register
So that I can access the platform So that I can access the platform
Scenario: Successful user registration Scenario: Successful user registration
@@ -228,45 +238,51 @@ Scenario: Successful user registration
And my account should be created And my account should be created
``` ```
### Infrastructure.Repository.Tests ### Features.Auth.Tests
``` ```
Infrastructure.Repository.Tests/ Features.Auth.Tests/
├── AuthRepositoryTests.cs # Auth repository tests ├── Commands/
├── UserAccountRepositoryTests.cs # User account tests │ ├── RegisterUserHandlerTests.cs
└── TestFixtures/ │ ├── ConfirmUserHandlerTests.cs
── DatabaseFixture.cs # Shared test setup ── ResendConfirmationEmailHandlerTests.cs
│ └── RefreshTokenHandlerTests.cs
├── Queries/
│ └── LoginHandlerTests.cs
├── Repository/
│ └── AuthRepositoryTests.cs # DbMocker-backed repository tests
└── Services/
├── TokenServiceRefreshTests.cs
└── TokenServiceValidationTests.cs
``` ```
### Service.Auth.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.
Service.Auth.Tests/
├── LoginService.test.cs # Login business logic tests
└── RegisterService.test.cs # Registration business logic tests
```
## Writing Tests ## Writing Tests
### Unit Test Example (xUnit) ### Unit Test Example (xUnit)
```csharp ```csharp
public class LoginServiceTests public class LoginHandlerTests
{ {
[Fact] [Fact]
public async Task LoginAsync_ValidCredentials_ReturnsToken() public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
{ {
// Arrange // Arrange
var mockRepo = new Mock<IAuthRepository>(); var authRepoMock = new Mock<IAuthRepository>();
var mockJwt = new Mock<IJwtService>(); var passwordInfraMock = new Mock<IPasswordInfrastructure>();
var service = new AuthService(mockRepo.Object, mockJwt.Object); var tokenServiceMock = new Mock<ITokenService>();
var handler = new LoginHandler(authRepoMock.Object, passwordInfraMock.Object, tokenServiceMock.Object);
// ...set up mocks for a known user/credential...
// Act // Act
var result = await service.LoginAsync("testuser", "password123"); var result = await handler.Handle(new LoginQuery("testuser", "password123"), CancellationToken.None);
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.Token.Should().NotBeNullOrEmpty(); result.AccessToken.Should().NotBeNullOrEmpty();
} }
} }
``` ```
@@ -288,9 +304,9 @@ configuration:
```bash ```bash
# CI/CD command # CI/CD command
docker compose -f docker-compose.test.yaml build docker compose --env-file web/.env.test -f web/docker-compose.test.yaml build
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
docker compose -f docker-compose.test.yaml down -v docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
``` ```
Exit codes: Exit codes:
@@ -302,7 +318,7 @@ Frontend UI checks should also be included in CI for the active website
workspace: workspace:
```bash ```bash
cd src/Website cd web/frontend
npm ci npm ci
npm run test:storybook npm run test:storybook
npm run test:storybook:playwright npm run test:storybook:playwright
@@ -315,7 +331,7 @@ npm run test:storybook:playwright
Ensure SQL Server is running and environment variables are set: Ensure SQL Server is running and environment variables are set:
```bash ```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 ### 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: Clean up test database:
```bash ```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 ### View Container Logs
```bash ```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 ## Best Practices

View File

@@ -2,8 +2,7 @@
## Overview ## Overview
The Core project implements comprehensive JWT token validation across three The Core project validates JWTs across three token types:
token types:
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication - **Access Tokens**: Short-lived (1 hour) tokens for API authentication
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access - **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access
@@ -15,7 +14,7 @@ token types:
### Infrastructure Layer ### Infrastructure Layer
#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs) #### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs)
Low-level JWT operations. Low-level JWT operations.
@@ -25,77 +24,92 @@ Low-level JWT operations.
- `ValidateJwtAsync()` - Validates token signature, expiration, and format - `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:** **Implementation:**
[JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs) [JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler - Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256) - Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness - 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:** **Generation 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:**
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token - `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token - `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation - `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation
token 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 ### Integration Points
#### [ConfirmationService](Service.Auth/IConfirmationService.cs) #### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
**Flow:** **Flow:**
1. Receives confirmation token from user 1. Receives confirmation token from user via `ConfirmUserCommand`
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()` 2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token 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 5. Returns confirmation result
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs) #### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
**Flow:** **Flow:**
1. Receives refresh token from user 1. Receives a user ID via `ResendConfirmationEmailCommand`
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()` 2. Looks up the user and checks they aren't already verified
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()` 3. Generates a fresh confirmation token via `ITokenService`
4. Issues new access and refresh tokens via `TokenService` 4. Sends `SendResendConfirmationEmailCommand` over MediatR, handled by the
5. Returns new token pair `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:** **Endpoints:**
- `POST /api/auth/register` - Register new user - `POST /api/auth/register` - Register new user
- `POST /api/auth/login` - Authenticate user - `POST /api/auth/login` - Authenticate user
- `POST /api/auth/confirm?token=...` - Confirm email - `POST /api/auth/confirm?token=...` - Confirm email
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
- `POST /api/auth/refresh` - Refresh access token - `POST /api/auth/refresh` - Refresh access token
## Validation Security ## Validation Security
@@ -158,32 +172,44 @@ Validation failures return HTTP 401 Unauthorized:
1. **Generation**: During user registration (30-minute validity) 1. **Generation**: During user registration (30-minute validity)
2. **Delivery**: Emailed to user in confirmation link 2. **Delivery**: Emailed to user in confirmation link
3. **Usage**: User clicks link, token posted to `/api/auth/confirm` 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 5. **Completion**: User account marked as confirmed
6. **Expiration**: Token becomes invalid after 30 minutes 6. **Expiration**: Token becomes invalid after 30 minutes
## Testing ## Testing
All of the following live in `Features.Auth.Tests`.
### Unit Tests ### Unit Tests
**TokenValidationService.test.cs** **Services/TokenServiceValidationTests.cs**
- Happy path: Valid token extraction - Happy path: Valid token extraction
- Error cases: Invalid, expired, malformed tokens - Error cases: Invalid, expired, malformed tokens
- Missing/invalid claims scenarios - Missing/invalid claims scenarios
**RefreshTokenService.test.cs** **Services/TokenServiceRefreshTests.cs**
- Successful refresh with valid token - Successful refresh with valid token
- Invalid/expired refresh token rejection - Invalid/expired refresh token rejection
- Non-existent user handling - 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 - Successful confirmation with valid token
- Token validation failures - Token validation failures
- User not found scenarios - 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) ### BDD Tests (Reqnroll)
**TokenRefresh.feature** **TokenRefresh.feature**

View File

@@ -49,8 +49,10 @@ CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
# provider (e.g., SendGrid, Mailgun, Amazon SES). # provider (e.g., SendGrid, Mailgun, Amazon SES).
SMTP_HOST=mailpit SMTP_HOST=mailpit
SMTP_PORT=1025 SMTP_PORT=1025
SMTP_USERNAME= SMTP_USERNAME=ayerble
SMTP_PASSWORD= SMTP_PASSWORD=checkers
SMTP_USE_SSL=false SMTP_USE_SSL=false
SMTP_FROM_EMAIL=noreply@thebiergarten.app SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten 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, "useTabs": false,
"indentSize": 4, "indentSize": 4,
"endOfLine": "lf", "endOfLine": "lf",

View File

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

View File

@@ -1,13 +1,22 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json;
using API.Core.Contracts.Common;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Shared.Contracts;
namespace API.Core.Authentication; 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( public class JwtAuthenticationHandler(
IOptionsMonitor<JwtAuthenticationOptions> options, IOptionsMonitor<JwtAuthenticationOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
@@ -16,71 +25,76 @@ public class JwtAuthenticationHandler(
IConfiguration configuration IConfiguration configuration
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder) ) : 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() protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{ {
// Use the same access-token secret source as TokenService to avoid mismatched validation. // 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)) if (string.IsNullOrWhiteSpace(secret))
{
secret = configuration["Jwt:SecretKey"]; secret = configuration["Jwt:SecretKey"];
}
if (string.IsNullOrWhiteSpace(secret)) if (string.IsNullOrWhiteSpace(secret))
{
return AuthenticateResult.Fail("JWT secret is not configured"); return AuthenticateResult.Fail("JWT secret is not configured");
}
// Check if Authorization header exists // Check if Authorization header exists
if ( if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
!Request.Headers.TryGetValue(
"Authorization",
out var authHeaderValue
)
)
{
return AuthenticateResult.Fail("Authorization header is missing"); return AuthenticateResult.Fail("Authorization header is missing");
}
var authHeader = authHeaderValue.ToString(); string authHeader = authHeaderValue.ToString();
if ( if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
!authHeader.StartsWith( return AuthenticateResult.Fail("Invalid authorization header format");
"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 try
{ {
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync( ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
token, token,
secret secret
); );
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name); AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
return AuthenticateResult.Success(ticket); return AuthenticateResult.Success(ticket);
} }
catch (Exception ex) catch (Exception ex)
{ {
return AuthenticateResult.Fail( return AuthenticateResult.Fail($"Token validation failed: {ex.Message}");
$"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) protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{ {
Response.ContentType = "application/json"; Response.ContentType = "application/json";
Response.StatusCode = 401; 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); 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 { } 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; 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] [ApiController]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required [Route("error")] // required
public class NotFoundController : ControllerBase 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 [HttpGet("404")] //required
public IActionResult Handle404() public IActionResult Handle404()
{ {
return NotFound(new { message = "Route not found." }); return NotFound(new { message = "Route not found." });
} }
} }
}

View File

@@ -1,20 +1,31 @@
using System.Security.Claims; using System.Security.Claims;
using API.Core.Contracts.Common;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace API.Core.Controllers; namespace API.Core.Controllers;
/// <summary>
/// Sample controller demonstrating an endpoint that requires JWT authentication.
/// </summary>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")] [Authorize(AuthenticationSchemes = "JWT")]
public class ProtectedController : ControllerBase 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] [HttpGet]
public ActionResult<ResponseBody<object>> Get() public ActionResult<ResponseBody<object>> Get()
{ {
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var username = User.FindFirst(ClaimTypes.Name)?.Value; string? username = User.FindFirst(ClaimTypes.Name)?.Value;
return Ok( return Ok(
new ResponseBody<object> 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 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src 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 ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] 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.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "API/API.Core/API.Core.csproj" RUN dotnet restore "Core.slnx"
COPY . . COPY . .
WORKDIR "/src/API/API.Core" WORKDIR "/src/API/API.Core"
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -1,30 +1,89 @@
// API.Core/Filters/GlobalExceptionFilter.cs // API.Core/Filters/GlobalExceptionFilter.cs
using API.Core.Contracts.Common;
using Domain.Exceptions; using Domain.Exceptions;
using FluentValidation;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using Shared.Contracts;
using ValidationException = FluentValidation.ValidationException;
namespace API.Core; namespace API.Core;
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) /// <summary>
: IExceptionFilter /// 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) public void OnException(ExceptionContext context)
{ {
logger.LogError(context.Exception, "Unhandled exception occurred"); logger.LogError(context.Exception, "Unhandled exception occurred");
switch (context.Exception) switch (context.Exception)
{ {
case FluentValidation.ValidationException fluentValidationException: case ValidationException fluentValidationException:
var errors = fluentValidationException Dictionary<string, string[]> errors = fluentValidationException
.Errors.GroupBy(e => e.PropertyName) .Errors.GroupBy(e => e.PropertyName)
.ToDictionary( .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
context.Result = new BadRequestObjectResult( context.Result = new BadRequestObjectResult(
new { message = "Validation failed", errors } new { message = "Validation failed", errors }
@@ -33,9 +92,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case ConflictException ex: case ConflictException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 409, StatusCode = 409,
}; };
@@ -43,9 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case NotFoundException ex: case NotFoundException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 404, StatusCode = 404,
}; };
@@ -53,9 +108,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case UnauthorizedException ex: case UnauthorizedException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 401, StatusCode = 401,
}; };
@@ -63,9 +116,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case ForbiddenException ex: case ForbiddenException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 403, StatusCode = 403,
}; };
@@ -83,9 +134,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
break; break;
case Domain.Exceptions.ValidationException ex: case Domain.Exceptions.ValidationException ex:
context.Result = new ObjectResult( context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
new ResponseBody { Message = ex.Message }
)
{ {
StatusCode = 400, StatusCode = 400,
}; };
@@ -94,10 +143,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
default: default:
context.Result = new ObjectResult( context.Result = new ObjectResult(
new ResponseBody new ResponseBody { Message = "An unexpected error occurred" }
{
Message = "An unexpected error occurred",
}
) )
{ {
StatusCode = 500, StatusCode = 500,

View File

@@ -1,26 +1,30 @@
using API.Core; using API.Core;
using API.Core.Authentication; 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;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
using Infrastructure.Jwt; using Infrastructure.Jwt;
using Infrastructure.PasswordHashing; using Infrastructure.Sql;
using Infrastructure.Repository.Auth; using Shared.Application.Behaviors;
using Infrastructure.Repository.Sql;
using Infrastructure.Repository.UserAccount;
using Infrastructure.Repository.Breweries;
using Service.Auth;
using Service.Emails;
using Service.UserManagement.User;
var builder = WebApplication.CreateBuilder(args); WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Global Exception Filter // Global Exception Filter
builder.Services.AddControllers(options => builder
.Services.AddControllers(options =>
{ {
options.Filters.Add<GlobalExceptionFilter>(); options.Filters.Add<GlobalExceptionFilter>();
}); })
.AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
@@ -28,8 +32,23 @@ builder.Services.AddOpenApi();
// Add FluentValidation // Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); 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(); 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 // Add health checks
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
@@ -37,32 +56,20 @@ builder.Services.AddHealthChecks();
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddConsole(); builder.Logging.AddConsole();
if (!builder.Environment.IsProduction()) if (!builder.Environment.IsProduction())
{
builder.Logging.AddDebug(); builder.Logging.AddDebug();
}
// Configure Dependency Injection ------------------------------------------------------------------------------------- // Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddSingleton< builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
ISqlConnectionFactory,
DefaultSqlConnectionFactory
>();
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>(); builder.Services.AddFeaturesBreweries();
builder.Services.AddScoped<IAuthRepository, AuthRepository>(); builder.Services.AddFeaturesUserManagement();
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>(); builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<ILoginService, LoginService>();
builder.Services.AddScoped<IRegisterService, RegisterService>();
builder.Services.AddScoped<ITokenService, TokenService>();
// 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<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 // Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>(); builder.Services.AddScoped<GlobalExceptionFilter>();
@@ -70,14 +77,11 @@ builder.Services.AddScoped<GlobalExceptionFilter>();
// Configure JWT Authentication // Configure JWT Authentication
builder builder
.Services.AddAuthentication("JWT") .Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>( .AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
"JWT",
options => { }
);
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
var app = builder.Build(); WebApplication app = builder.Build();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
@@ -95,7 +99,7 @@ app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound"); app.MapFallbackToController("Handle404", "NotFound");
// Graceful shutdown handling // Graceful shutdown handling
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>(); IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() => lifetime.ApplicationStopping.Register(() =>
{ {
app.Logger.LogInformation("Application is shutting down gracefully..."); app.Logger.LogInformation("Application is shutting down gracefully...");

View File

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

View File

@@ -1,17 +1,32 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src 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.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"] COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"] COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"] COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"] 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.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"] COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Service/Service.UserManagement/Service.UserManagement.csproj", "Service/Service.UserManagement/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "API/API.Specs/API.Specs.csproj" RUN dotnet restore "Core.slnx"
COPY . . COPY . .
WORKDIR "/src/API/API.Specs" WORKDIR "/src/API/API.Specs"
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build 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 As a newly registered user
I want to confirm my email address via a validation token I want to confirm my email address via a validation token
So that my account is fully activated So that my account is fully activated
Scenario: Successful confirmation with valid token Scenario: Successful confirmation with valid token
Given the API is running Given the API is running
And I have registered a new account And I have registered a new account

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
using System.Text;
using System.Text.Json; using System.Text.Json;
using API.Specs;
using FluentAssertions; using FluentAssertions;
using Reqnroll; using Reqnroll;
@@ -15,14 +15,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
private HttpClient GetClient() private HttpClient GetClient()
{ {
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client)) if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
{
return client; return client;
}
var factory = scenario.TryGetValue<TestApiFactory>( TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey, FactoryKey,
out var f out TestApiFactory? f
) )
? f ? f
: new TestApiFactory(); : new TestApiFactory();
@@ -46,37 +44,27 @@ public class ApiGeneralSteps(ScenarioContext scenario)
string jsonBody 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( Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
jsonBody,
System.Text.Encoding.UTF8,
"application/json"
),
}; };
var response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
} }
[When("I send an HTTP request {string} to {string}")] [When("I send an HTTP request {string} to {string}")]
public async Task WhenISendAnHttpRequestStringToString( public async Task WhenISendAnHttpRequestStringToString(string method, string url)
string method,
string url
)
{ {
var client = GetClient(); HttpClient client = GetClient();
var requestMessage = new HttpRequestMessage( HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
new HttpMethod(method), HttpResponseMessage response = await client.SendAsync(requestMessage);
url string responseBody = await response.Content.ReadAsStringAsync();
);
var response = await client.SendAsync(requestMessage);
var responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; scenario[ResponseBodyKey] = responseBody;
@@ -86,7 +74,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseStatusCodeShouldBeInt(int expected) public void ThenTheResponseStatusCodeShouldBeInt(int expected)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
((int)response!.StatusCode).Should().Be(expected); ((int)response!.StatusCode).Should().Be(expected);
@@ -96,57 +84,42 @@ public class ApiGeneralSteps(ScenarioContext scenario)
public void ThenTheResponseHasHttpStatusInt(int expectedCode) public void ThenTheResponseHasHttpStatusInt(int expectedCode)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue("No response was received from the API"); .BeTrue("No response was received from the API");
((int)response!.StatusCode).Should().Be(expectedCode); ((int)response!.StatusCode).Should().Be(expectedCode);
} }
[Then("the response JSON should have {string} equal {string}")] [Then("the response JSON should have {string} equal {string}")]
public void ThenTheResponseJsonShouldHaveStringEqualString( public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
string field,
string expected
)
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; 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() .Should()
.BeTrue( .BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'", "Expected field '{0}' to be present either at the root or inside 'payload'",
field field
); );
payloadElem payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem payloadElem
.TryGetProperty(field, out value) .TryGetProperty(field, out value)
.Should() .Should()
.BeTrue( .BeTrue("Expected field '{0}' to be present inside 'payload'", field);
"Expected field '{0}' to be present inside 'payload'",
field
);
} }
value value
.ValueKind.Should() .ValueKind.Should()
.Be( .Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
JsonValueKind.String,
"Expected field '{0}' to be a string",
field
);
value.GetString().Should().Be(expected); value.GetString().Should().Be(expected);
} }
@@ -157,45 +130,33 @@ public class ApiGeneralSteps(ScenarioContext scenario)
) )
{ {
scenario scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using var doc = JsonDocument.Parse(responseBody!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
var root = doc.RootElement; 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() .Should()
.BeTrue( .BeTrue(
"Expected field '{0}' to be present either at the root or inside 'payload'", "Expected field '{0}' to be present either at the root or inside 'payload'",
field field
); );
payloadElem payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
.ValueKind.Should()
.Be(JsonValueKind.Object, "payload must be an object");
payloadElem payloadElem
.TryGetProperty(field, out value) .TryGetProperty(field, out value)
.Should() .Should()
.BeTrue( .BeTrue("Expected field '{0}' to be present inside 'payload'", field);
"Expected field '{0}' to be present inside 'payload'",
field
);
} }
value value
.ValueKind.Should() .ValueKind.Should()
.Be( .Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
JsonValueKind.String, string? actualValue = value.GetString();
"Expected field '{0}' to be a string",
field
);
var actualValue = value.GetString();
actualValue actualValue
.Should() .Should()
.Contain( .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 API.Specs.Mocks;
using Features.Emails.Services;
using Infrastructure.Email; using Infrastructure.Email;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Service.Emails;
namespace API.Specs namespace API.Specs;
{
public class TestApiFactory : WebApplicationFactory<Program> public class TestApiFactory : WebApplicationFactory<Program>
{ {
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -18,29 +16,24 @@ namespace API.Specs
builder.ConfigureServices(services => builder.ConfigureServices(services =>
{ {
// Replace the real email provider with mock for testing // Replace the real email provider with mock for testing
var emailProviderDescriptor = services.SingleOrDefault(d => ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider) d.ServiceType == typeof(IEmailProvider)
); );
if (emailProviderDescriptor != null) if (emailProviderDescriptor != null)
{
services.Remove(emailProviderDescriptor); services.Remove(emailProviderDescriptor);
}
services.AddScoped<IEmailProvider, MockEmailProvider>(); services.AddScoped<IEmailProvider, MockEmailProvider>();
// Replace the real email service with mock for testing // Replace the real email dispatcher with mock for testing
var emailServiceDescriptor = services.SingleOrDefault(d => ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailService) d.ServiceType == typeof(IEmailDispatcher)
); );
if (emailServiceDescriptor != null) if (emailDispatcherDescriptor != null)
{ services.Remove(emailDispatcherDescriptor);
services.Remove(emailServiceDescriptor);
}
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.Jwt/Infrastructure.Jwt.csproj"/>
<Project <Project
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/> Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj"/>
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" /> <Project Path="Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj"/>
<Project
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/Service/"> <Folder Name="/Features/">
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" /> <Project Path="Features/Features.Breweries/Features.Breweries.csproj"/>
<Project Path="Service/Service.Emails/Service.Emails.csproj" /> <Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj"/>
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" /> <Project Path="Features/Features.UserManagement/Features.UserManagement.csproj"/>
<Project Path="Service/Service.Auth/Service.Auth.csproj" /> <Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj"/>
<Project Path="Service/Service.Breweries/Service.Breweries.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> </Folder>
</Solution> </Solution>

View File

@@ -1,95 +1,136 @@
using System.Data; using System.Data;
using System.Reflection; using System.Reflection;
using DbUp; using DbUp;
using DbUp.Engine;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace Database.Migrations; 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 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) 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"); ?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = databaseName string dbName =
databaseName
?? Environment.GetEnvironmentVariable("DB_NAME") ?? Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); ?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER") string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set"); ?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD") string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate =
?? "True"; Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder SqlConnectionStringBuilder builder = new()
{ {
DataSource = server, DataSource = server,
InitialCatalog = dbName, InitialCatalog = dbName,
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate), TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true Encrypt = true,
}; };
return builder.ConnectionString; return builder.ConnectionString;
} }
private static readonly string connectionString = BuildConnectionString(); /// <summary>
private static readonly string masterConnectionString = BuildConnectionString("master"); /// 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() private static bool DeployMigrations()
{ {
var upgrader = DeployChanges UpgradeEngine? upgrader = DeployChanges
.To.SqlDatabase(connectionString) .To.SqlDatabase(connectionString)
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly()) .WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
.LogToConsole() .LogToConsole()
.Build(); .Build();
var result = upgrader.PerformUpgrade(); DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
return result.Successful; 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() private static bool ClearDatabase()
{ {
var myConn = new SqlConnection(masterConnectionString); SqlConnection myConn = new(masterConnectionString);
try try
{ {
myConn.Open(); myConn.Open();
// First, set the database to single user mode to close all connections // First, set the database to single user mode to close all connections
var setModeCommand = new SqlCommand( SqlCommand setModeCommand = new(
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " + "IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') "
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;", + "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
myConn); myConn
);
try try
{ {
setModeCommand.ExecuteNonQuery(); setModeCommand.ExecuteNonQuery();
Console.WriteLine("Database set to single user mode."); 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}"); Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
} }
// Then drop the database // Then drop the database
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn); SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
try try
{ {
dropCommand.ExecuteNonQuery(); dropCommand.ExecuteNonQuery();
Console.WriteLine("Database cleared successfully."); Console.WriteLine("Database cleared successfully.");
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error dropping database: {ex}"); Console.WriteLine($"Error dropping database: {ex}");
return false; return false;
} }
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error clearing database: {ex}"); Console.WriteLine($"Error clearing database: {ex}");
return false; return false;
@@ -97,50 +138,61 @@ public static class Program
finally finally
{ {
if (myConn.State == ConnectionState.Open) if (myConn.State == ConnectionState.Open)
{
myConn.Close(); myConn.Close();
} }
}
return true; 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() private static bool CreateDatabaseIfNotExists()
{ {
var myConn = new SqlConnection(masterConnectionString); SqlConnection myConn = new(masterConnectionString);
const string str = """ const string str = """
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
CREATE DATABASE [Biergarten] CREATE DATABASE [Biergarten]
"""; """;
var myCommand = new SqlCommand(str, myConn); SqlCommand myCommand = new(str, myConn);
try try
{ {
myConn.Open(); myConn.Open();
myCommand.ExecuteNonQuery(); myCommand.ExecuteNonQuery();
Console.WriteLine("Database creation command executed successfully."); Console.WriteLine("Database creation command executed successfully.");
} }
catch (System.Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error creating database: {ex}"); Console.WriteLine($"Error creating database: {ex}");
} }
finally finally
{ {
if (myConn.State == ConnectionState.Open) if (myConn.State == ConnectionState.Open)
{
myConn.Close(); myConn.Close();
} }
}
return true; 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) public static int Main(string[] args)
{ {
Console.WriteLine("Starting database migrations..."); Console.WriteLine("Starting database migrations...");
try try
{ {
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE"); string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
if (clearDatabase == "true") if (clearDatabase == "true")
{ {
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database..."); Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
@@ -148,19 +200,17 @@ public static class Program
} }
CreateDatabaseIfNotExists(); CreateDatabaseIfNotExists();
var success = DeployMigrations(); bool success = DeployMigrations();
if (success) if (success)
{ {
Console.WriteLine("Database migrations completed successfully."); Console.WriteLine("Database migrations completed successfully.");
return 0; return 0;
} }
else
{
Console.WriteLine("Database migrations failed."); Console.WriteLine("Database migrations failed.");
return 1; return 1;
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine("An error occurred during database migrations:"); 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 ON DELETE NO ACTION
); );
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID CREATE
NONCLUSTERED INDEX IX_Photo_UploadedByID
ON Photo(UploadedByID); ON Photo(UploadedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -110,7 +111,8 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID); ON UserAvatar(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -140,7 +142,8 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
UNIQUE (UserAccountID) UNIQUE (UserAccountID)
); );
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount CREATE
NONCLUSTERED INDEX IX_UserVerification_UserAccount
ON UserVerification(UserAccountID); ON UserVerification(UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -178,10 +181,12 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount CREATE
NONCLUSTERED INDEX IX_UserCredential_UserAccount
ON UserCredential(UserAccountID); ON UserCredential(UserAccountID);
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active CREATE
NONCLUSTERED INDEX IX_UserCredential_Account_Active
ON UserCredential(UserAccountID, IsRevoked, Expiry) ON UserCredential(UserAccountID, IsRevoked, Expiry)
INCLUDE (Hash); INCLUDE (Hash);
@@ -216,13 +221,16 @@ CREATE TABLE UserFollow
ON DELETE NO ACTION, ON DELETE NO ACTION,
CONSTRAINT CK_CannotFollowOwnAccount 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); ON UserFollow(UserAccountID, FollowingID);
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount CREATE
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
ON UserFollow(FollowingID, UserAccountID); ON UserFollow(FollowingID, UserAccountID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -274,7 +282,8 @@ CREATE TABLE StateProvince
REFERENCES Country (CountryID) REFERENCES Country (CountryID)
); );
CREATE NONCLUSTERED INDEX IX_StateProvince_Country CREATE
NONCLUSTERED INDEX IX_StateProvince_Country
ON StateProvince(CountryID); ON StateProvince(CountryID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -299,7 +308,8 @@ CREATE TABLE City
REFERENCES StateProvince (StateProvinceID) REFERENCES StateProvince (StateProvinceID)
); );
CREATE NONCLUSTERED INDEX IX_City_StateProvince CREATE
NONCLUSTERED INDEX IX_City_StateProvince
ON City(StateProvinceID); ON City(StateProvinceID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -332,7 +342,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
ON DELETE NO ACTION ON DELETE NO ACTION
); );
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID CREATE
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
ON BreweryPost(PostedByID); ON BreweryPost(PostedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -373,10 +384,12 @@ CREATE TABLE BreweryPostLocation
REFERENCES City (CityID) REFERENCES City (CityID)
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
ON BreweryPostLocation(BreweryPostID); ON BreweryPostLocation(BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City CREATE
NONCLUSTERED INDEX IX_BreweryPostLocation_City
ON BreweryPostLocation(CityID); ON BreweryPostLocation(CityID);
-- To assess when the time comes: -- 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 ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
ON BreweryPostPhoto(PhotoID, BreweryPostID); ON BreweryPostPhoto(PhotoID, BreweryPostID);
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo CREATE
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
ON BreweryPostPhoto(BreweryPostID, PhotoID); ON BreweryPostPhoto(BreweryPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
CHECK (IBU >= 0 AND IBU <= 120) CHECK (IBU >= 0 AND IBU <= 120)
); );
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy CREATE
NONCLUSTERED INDEX IX_BeerPost_PostedBy
ON BeerPost(PostedByID); ON BeerPost(PostedByID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle CREATE
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
ON BeerPost(BeerStyleID); ON BeerPost(BeerStyleID);
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy CREATE
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
ON BeerPost(BrewedByID); ON BeerPost(BrewedByID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -543,10 +561,12 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
ON DELETE CASCADE ON DELETE CASCADE
); );
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
ON BeerPostPhoto(PhotoID, BeerPostID); ON BeerPostPhoto(PhotoID, BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo CREATE
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
ON BeerPostPhoto(BeerPostID, PhotoID); ON BeerPostPhoto(BeerPostID, PhotoID);
---------------------------------------------------------------------------- ----------------------------------------------------------------------------
@@ -588,8 +608,10 @@ CREATE TABLE BeerPostComment
CHECK (Rating BETWEEN 1 AND 5) CHECK (Rating BETWEEN 1 AND 5)
); );
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost CREATE
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
ON BeerPostComment(BeerPostID); ON BeerPostComment(BeerPostID);
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy CREATE
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
ON BeerPostComment(CommentedByID); 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) @CountryCode NVARCHAR(2)
) )
RETURNS UNIQUEIDENTIFIER RETURNS UNIQUEIDENTIFIER
AS AS
BEGIN BEGIN
DECLARE @CountryId UNIQUEIDENTIFIER; DECLARE
@CountryId UNIQUEIDENTIFIER;
SELECT @CountryId = CountryID SELECT @CountryId = CountryID
FROM dbo.Country 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) @StateProvinceCode NVARCHAR(6)
) )
RETURNS UNIQUEIDENTIFIER RETURNS UNIQUEIDENTIFIER
AS AS
BEGIN BEGIN
DECLARE @StateProvinceId UNIQUEIDENTIFIER; DECLARE
@StateProvinceId UNIQUEIDENTIFIER;
SELECT @StateProvinceId = StateProvinceID SELECT @StateProvinceId = StateProvinceID
FROM dbo.StateProvince FROM dbo.StateProvince
WHERE ISO3166_2 = @StateProvinceCode; WHERE ISO3166_2 = @StateProvinceCode;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount( CREATE
OR
ALTER PROCEDURE usp_UpdateUserAccount(
@Username VARCHAR (64), @Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@@ -19,7 +21,8 @@ BEGIN
Email = @Email Email = @Email
WHERE UserAccountId = @UserAccountId; WHERE UserAccountId = @UserAccountId;
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
BEGIN BEGIN
THROW THROW
50001, 'UserAccount with the specified ID does not exist.', 1; 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 @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
NOCOUNT ON;
SELECT SELECT UserCredentialId,
UserCredentialId,
UserAccountId, UserAccountId,
Hash, Hash,
IsRevoked, IsRevoked,
CreatedAt, CreatedAt,
RevokedAt RevokedAt
FROM dbo.UserCredential FROM dbo.UserCredential
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0; WHERE UserAccountId = @UserAccountId
AND IsRevoked = 0;
END; END;

View File

@@ -1,15 +1,21 @@
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential( CREATE
OR
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER @UserAccountId_ UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION; BEGIN
TRANSACTION;
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_; EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
THROW 50001, 'User account not found', 1; THROW 50001, 'User account not found', 1;
-- invalidate all other credentials by setting them to revoked -- 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), @Username VARCHAR (64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@@ -8,12 +10,16 @@ CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
DECLARE @UserAccountId_ UNIQUEIDENTIFIER; DECLARE
@UserAccountId_ UNIQUEIDENTIFIER;
BEGIN TRANSACTION; BEGIN
TRANSACTION;
EXEC usp_CreateUserAccount EXEC usp_CreateUserAccount
@UserAccountId = @UserAccountId_ OUTPUT, @UserAccountId = @UserAccountId_ OUTPUT,
@@ -23,18 +29,22 @@ BEGIN
@DateOfBirth = @DateOfBirth, @DateOfBirth = @DateOfBirth,
@Email = @Email; @Email = @Email;
IF @UserAccountId_ IS NULL IF
@UserAccountId_ IS NULL
BEGIN BEGIN
THROW 50000, 'Failed to create user account.', 1; THROW
50000, 'Failed to create user account.', 1;
END END
INSERT INTO dbo.UserCredential INSERT INTO dbo.UserCredential
(UserAccountId, Hash) (UserAccountId, Hash)
VALUES (@UserAccountId_, @Hash); VALUES (@UserAccountId_, @Hash);
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
BEGIN BEGIN
THROW 50002, 'Failed to create user credential.', 1; THROW
50002, 'Failed to create user credential.', 1;
END END
COMMIT TRANSACTION; COMMIT TRANSACTION;

View File

@@ -1,12 +1,17 @@
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential( CREATE
OR
ALTER PROCEDURE dbo.USP_RotateUserCredential(
@UserAccountId_ UNIQUEIDENTIFIER, @UserAccountId_ UNIQUEIDENTIFIER,
@Hash NVARCHAR(MAX) @Hash NVARCHAR(MAX)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
BEGIN TRANSACTION; SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_ 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 @VerificationDateTime DATETIME = NULL
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF @VerificationDateTime IS NULL IF
@VerificationDateTime IS NULL
SET @VerificationDateTime = GETDATE(); SET @VerificationDateTime = GETDATE();
BEGIN TRANSACTION; BEGIN
TRANSACTION;
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_; EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
IF @@ROWCOUNT = 0 IF
@@ROWCOUNT = 0
THROW 50001, 'Could not find a user with that id', 1; THROW 50001, 'Could not find a user with that id', 1;
INSERT INTO dbo.UserVerification 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), @CityName NVARCHAR(100),
@StateProvinceCode NVARCHAR(6) @StateProvinceCode NVARCHAR(6)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
BEGIN TRANSACTION
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
IF @StateProvinceId IS NULL
BEGIN 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 END
IF EXISTS (SELECT 1 IF
EXISTS (SELECT 1
FROM dbo.City FROM dbo.City
WHERE CityName = @CityName WHERE CityName = @CityName
AND StateProvinceID = @StateProvinceId) AND StateProvinceID = @StateProvinceId)
BEGIN BEGIN
THROW 50002, 'City already exists.', 1; THROW
50002, 'City already exists.', 1;
END END
INSERT INTO dbo.City 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), @CountryName NVARCHAR(100),
@ISO3166_1 NVARCHAR(2) @ISO3166_1 NVARCHAR(2)
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
BEGIN TRANSACTION; SET
XACT_ABORT ON;
BEGIN
TRANSACTION;
IF EXISTS (SELECT 1 IF
EXISTS (SELECT 1
FROM dbo.Country FROM dbo.Country
WHERE ISO3166_1 = @ISO3166_1) WHERE ISO3166_1 = @ISO3166_1)
THROW 50001, 'Country already exists', 1; THROW 50001, 'Country already exists', 1;
INSERT INTO dbo.Country INSERT INTO dbo.Country
(CountryName, ISO3166_1) (CountryName, ISO3166_1)
VALUES VALUES (@CountryName, @ISO3166_1);
(@CountryName, @ISO3166_1);
COMMIT TRANSACTION; COMMIT TRANSACTION;
END; END;

View File

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

View File

@@ -1,4 +1,6 @@
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery( CREATE
OR
ALTER PROCEDURE dbo.USP_CreateBrewery(
@BreweryName NVARCHAR(256), @BreweryName NVARCHAR(256),
@Description NVARCHAR(512), @Description NVARCHAR(512),
@PostedByID UNIQUEIDENTIFIER, @PostedByID UNIQUEIDENTIFIER,
@@ -10,29 +12,38 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
) )
AS AS
BEGIN BEGIN
SET NOCOUNT ON; SET
SET XACT_ABORT ON; NOCOUNT ON;
SET
XACT_ABORT ON;
IF @BreweryName IS NULL IF
@BreweryName IS NULL
THROW 50001, 'Brewery name cannot be null.', 1; THROW 50001, 'Brewery name cannot be null.', 1;
IF @Description IS NULL IF
@Description IS NULL
THROW 50002, 'Brewery description cannot be null.', 1; THROW 50002, 'Brewery description cannot be null.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.UserAccount FROM dbo.UserAccount
WHERE UserAccountID = @PostedByID) WHERE UserAccountID = @PostedByID)
THROW 50404, 'User not found.', 1; THROW 50404, 'User not found.', 1;
IF NOT EXISTS (SELECT 1 IF
NOT EXISTS (SELECT 1
FROM dbo.City FROM dbo.City
WHERE CityID = @CityID) WHERE CityID = @CityID)
THROW 50404, 'City not found.', 1; THROW 50404, 'City not found.', 1;
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID(); DECLARE
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID(); @NewBreweryID UNIQUEIDENTIFIER = NEWID();
DECLARE
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
BEGIN TRANSACTION; BEGIN
TRANSACTION;
INSERT INTO dbo.BreweryPost INSERT INTO dbo.BreweryPost
(BreweryPostID, BreweryName, Description, PostedByID) (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 AS
BEGIN BEGIN
SELECT * 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> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" /> <PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
<PackageReference <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
<PackageReference Include="dbup" Version="5.0.41" /> <PackageReference Include="dbup" Version="5.0.41" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -2,7 +2,18 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed; 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 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); Task SeedAsync(SqlConnection connection);
} }

View File

@@ -3,22 +3,34 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed; 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 internal class LocationSeeder : ISeeder
{ {
private static readonly IReadOnlyList<( /// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
string CountryName, private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
string CountryCode
)> Countries =
[ [
("Canada", "CA"), ("Canada", "CA"),
("Mexico", "MX"), ("Mexico", "MX"),
("United States", "US"), ("United States", "US"),
]; ];
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States /// <summary>
{ /// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
get; /// 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"), ("Alabama", "US-AL", "US"),
("Alaska", "US-AK", "US"), ("Alaska", "US-AK", "US"),
@@ -123,6 +135,10 @@ internal class LocationSeeder : ISeeder
("Ciudad de México", "MX-CMX", "MX"), ("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; } = private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
[ [
("US-CA", "Los Angeles"), ("US-CA", "Los Angeles"),
@@ -240,41 +256,41 @@ internal class LocationSeeder : ISeeder
("MX-ZAC", "Zacatecas"), ("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) public async Task SeedAsync(SqlConnection connection)
{ {
foreach (var (countryName, countryCode) in Countries) foreach ((string countryName, string countryCode) in Countries)
{
await CreateCountryAsync(connection, countryName, countryCode); await CreateCountryAsync(connection, countryName, countryCode);
}
foreach ( foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
var (stateProvinceName, stateProvinceCode, countryCode) in States
)
{
await CreateStateProvinceAsync( await CreateStateProvinceAsync(
connection, connection,
stateProvinceName, stateProvinceName,
stateProvinceCode, stateProvinceCode,
countryCode countryCode
); );
}
foreach (var (stateProvinceCode, cityName) in Cities) foreach ((string stateProvinceCode, string cityName) in Cities)
{
await CreateCityAsync(connection, cityName, stateProvinceCode); 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( private static async Task CreateCountryAsync(
SqlConnection connection, SqlConnection connection,
string countryName, string countryName,
string countryCode string countryCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
"dbo.USP_CreateCountry",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CountryName", countryName); command.Parameters.AddWithValue("@CountryName", countryName);
command.Parameters.AddWithValue("@ISO3166_1", countryCode); command.Parameters.AddWithValue("@ISO3166_1", countryCode);
@@ -282,6 +298,12 @@ internal class LocationSeeder : ISeeder
await command.ExecuteNonQueryAsync(); 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( private static async Task CreateStateProvinceAsync(
SqlConnection connection, SqlConnection connection,
string stateProvinceName, string stateProvinceName,
@@ -289,37 +311,30 @@ internal class LocationSeeder : ISeeder
string countryCode string countryCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
"dbo.USP_CreateStateProvince",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue( command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
"@StateProvinceName",
stateProvinceName
);
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode); command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
command.Parameters.AddWithValue("@CountryCode", countryCode); command.Parameters.AddWithValue("@CountryCode", countryCode);
await command.ExecuteNonQueryAsync(); 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( private static async Task CreateCityAsync(
SqlConnection connection, SqlConnection connection,
string cityName, string cityName,
string stateProvinceCode string stateProvinceCode
) )
{ {
await using var command = new SqlCommand( await using SqlCommand command = new("dbo.USP_CreateCity", connection);
"dbo.USP_CreateCity",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@CityName", cityName); command.Parameters.AddWithValue("@CityName", cityName);
command.Parameters.AddWithValue( command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
"@StateProvinceCode",
stateProvinceCode
);
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
} }

View File

@@ -1,42 +1,57 @@
using Microsoft.Data.SqlClient; using Database.Seed;
using DbUp; using Microsoft.Data.SqlClient;
using System.Reflection;
using Database.Seed;
// 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() string BuildConnectionString()
{ {
var server = Environment.GetEnvironmentVariable("DB_SERVER") string server =
Environment.GetEnvironmentVariable("DB_SERVER")
?? throw new InvalidOperationException("DB_SERVER environment variable is not set"); ?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
var dbName = Environment.GetEnvironmentVariable("DB_NAME") string dbName =
Environment.GetEnvironmentVariable("DB_NAME")
?? throw new InvalidOperationException("DB_NAME environment variable is not set"); ?? throw new InvalidOperationException("DB_NAME environment variable is not set");
var user = Environment.GetEnvironmentVariable("DB_USER") string user =
Environment.GetEnvironmentVariable("DB_USER")
?? throw new InvalidOperationException("DB_USER environment variable is not set"); ?? throw new InvalidOperationException("DB_USER environment variable is not set");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD") string password =
Environment.GetEnvironmentVariable("DB_PASSWORD")
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set"); ?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") string trustServerCertificate =
?? "True"; Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
var builder = new SqlConnectionStringBuilder SqlConnectionStringBuilder builder = new()
{ {
DataSource = server, DataSource = server,
InitialCatalog = dbName, InitialCatalog = dbName,
UserID = user, UserID = user,
Password = password, Password = password,
TrustServerCertificate = bool.Parse(trustServerCertificate), TrustServerCertificate = bool.Parse(trustServerCertificate),
Encrypt = true Encrypt = true,
}; };
return builder.ConnectionString; return builder.ConnectionString;
} }
try try
{ {
var connectionString = BuildConnectionString(); string connectionString = BuildConnectionString();
Console.WriteLine("Attempting to connect to database..."); Console.WriteLine("Attempting to connect to database...");
@@ -46,7 +61,6 @@ try
int retryDelayMs = 2000; int retryDelayMs = 2000;
for (int attempt = 1; attempt <= maxRetries; attempt++) for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try try
{ {
connection = new SqlConnection(connectionString); connection = new SqlConnection(connectionString);
@@ -62,24 +76,17 @@ try
connection?.Dispose(); connection?.Dispose();
connection = null; connection = null;
} }
}
if (connection == null) if (connection == null)
{
throw new Exception($"Failed to connect to database after {maxRetries} attempts."); throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
}
Console.WriteLine("Starting seeding..."); Console.WriteLine("Starting seeding...");
using (connection) using (connection)
{ {
ISeeder[] seeders = ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
[
new LocationSeeder(),
new UserSeeder(),
];
foreach (var seeder in seeders) foreach (ISeeder seeder in seeders)
{ {
Console.WriteLine($"Seeding {seeder.GetType().Name}..."); Console.WriteLine($"Seeding {seeder.GetType().Name}...");
await seeder.SeedAsync(connection); await seeder.SeedAsync(connection);

View File

@@ -7,12 +7,19 @@ using Microsoft.Data.SqlClient;
namespace Database.Seed; 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 internal class UserSeeder : ISeeder
{ {
private static readonly IReadOnlyList<( /// <summary>The first/last name pairs used to generate seed user accounts.</summary>
string FirstName, private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
string LastName
)> SeedNames =
[ [
("Aarya", "Mathews"), ("Aarya", "Mathews"),
("Aiden", "Wells"), ("Aiden", "Wells"),
@@ -116,10 +123,18 @@ internal class UserSeeder : ISeeder
("Zoie", "Armstrong"), ("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) public async Task SeedAsync(SqlConnection connection)
{ {
var generator = new PasswordGenerator(); PasswordGenerator generator = new();
var rng = new Random(); Random rng = new();
int createdUsers = 0; int createdUsers = 0;
int createdCredentials = 0; int createdCredentials = 0;
int createdVerifications = 0; int createdVerifications = 0;
@@ -129,8 +144,8 @@ internal class UserSeeder : ISeeder
const string firstName = "Test"; const string firstName = "Test";
const string lastName = "User"; const string lastName = "User";
const string email = "test.user@thebiergarten.app"; const string email = "test.user@thebiergarten.app";
var dob = new DateTime(1985, 03, 01); DateTime dob = new(1985, 03, 01);
var hash = GeneratePasswordHash("password"); string hash = GeneratePasswordHash("password");
await RegisterUserAsync( await RegisterUserAsync(
connection, connection,
@@ -142,24 +157,19 @@ internal class UserSeeder : ISeeder
hash hash
); );
} }
foreach (var (firstName, lastName) in SeedNames) foreach ((string firstName, string lastName) in SeedNames)
{ {
// prepare user fields // prepare user fields
var username = $"{firstName[0]}.{lastName}"; string username = $"{firstName[0]}.{lastName}";
var email = $"{firstName}.{lastName}@thebiergarten.app"; string email = $"{firstName}.{lastName}@thebiergarten.app";
var dob = GenerateDateOfBirth(rng); DateTime dob = GenerateDateOfBirth(rng);
// generate a password and hash it // generate a password and hash it
string pwd = generator.Generate( string pwd = generator.Generate(64, 10, 10);
length: 64,
numberOfDigits: 10,
numberOfSymbols: 10
);
string hash = GeneratePasswordHash(pwd); string hash = GeneratePasswordHash(pwd);
// register the user (creates account + credential) // register the user (creates account + credential)
var id = await RegisterUserAsync( Guid id = await RegisterUserAsync(
connection, connection,
username, username,
firstName, firstName,
@@ -171,9 +181,9 @@ internal class UserSeeder : ISeeder
createdUsers++; createdUsers++;
createdCredentials++; createdCredentials++;
// add user verification // add user verification
if (await HasUserVerificationAsync(connection, id)) continue; if (await HasUserVerificationAsync(connection, id))
continue;
await AddUserVerificationAsync(connection, id); await AddUserVerificationAsync(connection, id);
createdVerifications++; createdVerifications++;
@@ -184,6 +194,18 @@ internal class UserSeeder : ISeeder
Console.WriteLine($"Added {createdVerifications} user verifications."); 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( private static async Task<Guid> RegisterUserAsync(
SqlConnection connection, SqlConnection connection,
string username, string username,
@@ -194,10 +216,9 @@ internal class UserSeeder : ISeeder
string hash 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.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username; command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName; command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName; 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("@Email", SqlDbType.VarChar, 128).Value = email;
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash; command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return (Guid)result!; 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) private static string GeneratePasswordHash(string pwd)
{ {
byte[] salt = RandomNumberGenerator.GetBytes(16); byte[] salt = RandomNumberGenerator.GetBytes(16);
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd)) Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
{ {
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
@@ -227,6 +254,10 @@ internal class UserSeeder : ISeeder
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; 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( private static async Task<bool> HasUserVerificationAsync(
SqlConnection connection, SqlConnection connection,
Guid userAccountId Guid userAccountId
@@ -237,27 +268,31 @@ internal class UserSeeder : ISeeder
FROM dbo.UserVerification FROM dbo.UserVerification
WHERE UserAccountId = @UserAccountId; WHERE UserAccountId = @UserAccountId;
"""; """;
await using var command = new SqlCommand(sql, connection); await using SqlCommand command = new(sql, connection);
command.Parameters.AddWithValue("@UserAccountId", userAccountId); command.Parameters.AddWithValue("@UserAccountId", userAccountId);
var result = await command.ExecuteScalarAsync(); object? result = await command.ExecuteScalarAsync();
return result is not null; return result is not null;
} }
private static async Task AddUserVerificationAsync( /// <summary>Creates a user verification record by invoking the <c>dbo.USP_CreateUserVerification</c> stored procedure.</summary>
SqlConnection connection, /// <param name="connection">An open connection to the target database.</param>
Guid userAccountId /// <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( await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
"dbo.USP_CreateUserVerification",
connection
);
command.CommandType = CommandType.StoredProcedure; command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountID_", userAccountId); command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
await command.ExecuteNonQueryAsync(); 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) private static DateTime GenerateDateOfBirth(Random random)
{ {
int age = 19 + random.Next(0, 30); 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; 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 public class BreweryPost
{ {
/// <summary>
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
/// </summary>
public Guid BreweryPostId { get; set; } 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; } public Guid PostedById { get; set; }
/// <summary>
/// The name of the brewery being posted about.
/// </summary>
public string BreweryName { get; set; } = string.Empty; public string BreweryName { get; set; } = string.Empty;
/// <summary>
/// Free-text description of the brewery.
/// </summary>
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary>
/// The date and time the post was created.
/// </summary>
public DateTime CreatedAt { get; set; } 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; } 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; } 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; } public BreweryPostLocation? Location { get; set; }
} }

View File

@@ -1,13 +1,52 @@
namespace Domain.Entities; 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 public class BreweryPostLocation
{ {
/// <summary>
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
/// </summary>
public Guid BreweryPostLocationId { get; set; } 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; } public Guid BreweryPostId { get; set; }
/// <summary>
/// The primary street address line.
/// </summary>
public string AddressLine1 { get; set; } = string.Empty; 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; } public string? AddressLine2 { get; set; }
/// <summary>
/// The postal/ZIP code of the location.
/// </summary>
public string PostalCode { get; set; } = string.Empty; 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; } 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; } 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; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,14 +1,55 @@
namespace Domain.Entities; 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 public class UserAccount
{ {
/// <summary>
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
/// </summary>
public Guid UserAccountId { get; set; } 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; public string Username { get; set; } = string.Empty;
/// <summary>
/// The user's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty;
/// <summary>
/// The user's last name.
/// </summary>
public string LastName { get; set; } = string.Empty; 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; public string Email { get; set; } = string.Empty;
/// <summary>
/// The date and time the account was created.
/// </summary>
public DateTime CreatedAt { get; set; } 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; } public DateTime? UpdatedAt { get; set; }
/// <summary>
/// The user's date of birth.
/// </summary>
public DateTime DateOfBirth { get; set; } 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; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,11 +1,40 @@
namespace Domain.Entities; 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 public class UserCredential
{ {
/// <summary>
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
/// </summary>
public Guid UserCredentialId { get; set; } 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; } public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time the credential was issued.
/// </summary>
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
/// <summary>
/// The date and time after which the credential is no longer valid.
/// </summary>
public DateTime Expiry { get; set; } 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; 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; } public byte[]? Timer { get; set; }
} }

View File

@@ -1,9 +1,31 @@
namespace Domain.Entities; 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 public class UserVerification
{ {
/// <summary>
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
/// </summary>
public Guid UserVerificationId { get; set; } 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; } public Guid UserAccountId { get; set; }
/// <summary>
/// The date and time at which the user account was verified.
/// </summary>
public DateTime VerificationDateTime { get; set; } 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; } public byte[]? Timer { get; set; }
} }

View File

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

View File

@@ -1,11 +1,14 @@
using System.Data.Common; 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 internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
{ {
private readonly DbConnection _conn = conn; 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; using FluentValidation;
namespace API.Core.Contracts.Auth; namespace Features.Auth.Commands.RegisterUser;
public record RegisterRequest( /// <summary>
string Username, /// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
string FirstName, /// </summary>
string LastName, public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
string Email,
DateTime DateOfBirth,
string Password
);
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{ {
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) RuleFor(x => x.Username)
.NotEmpty() .NotEmpty()
@@ -64,8 +61,6 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
.Matches("[0-9]") .Matches("[0-9]")
.WithMessage("Password must contain at least one number") .WithMessage("Password must contain at least one number")
.Matches("[^a-zA-Z0-9]") .Matches("[^a-zA-Z0-9]")
.WithMessage( .WithMessage("Password must contain at least one special character");
"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