mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
Compare commits
8 Commits
pipeline/f
...
0c3b0e99e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3b0e99e8 | ||
|
|
a1a63b11bb | ||
|
|
6db004066f | ||
|
|
a8c1b17095 | ||
|
|
62d682472e | ||
|
|
e07b46fcce | ||
|
|
d896f04274 | ||
|
|
254b2afb9f |
@@ -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/
|
||||||
|
|||||||
@@ -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 │
|
||||||
│ Service Layer (Business Logic) │
|
│ │Breweries │ UserManagement │ (no controller, │
|
||||||
│ - Authentication logic │
|
│ Controller │ Controller │ Controller │ internal only) │
|
||||||
│ - User management │
|
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │
|
||||||
│ - Validation & orchestration │
|
│ Queries + │ Queries + │ Queries + │ Handlers │
|
||||||
└─────────────────────────────────────┘
|
│ Handlers │ Handlers │ Handlers │ │
|
||||||
↓
|
│ Repository │ Repository │ Repository │ EmailDispatcher │
|
||||||
┌─────────────────────────────────────┐
|
└───────────────┴───────────────┴───────────────────┴───────────────────┘
|
||||||
│ Infrastructure Layer (Tools) │
|
↓ each slice depends only on shared/domain/infra, never on another slice
|
||||||
│ - JWT token generation │
|
┌─────────────────────────┬─────────────────────────┬────────────────────┐
|
||||||
│ - Password hashing (Argon2id) │
|
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │
|
||||||
│ - Email services │
|
│ (ResponseBody envelope) │ (ValidationBehavior, │ Domain.Exceptions │
|
||||||
│ - Repository implementations │
|
│ │ cross-slice email cmds) │ │
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────┴─────────────────────────┴────────────────────┘
|
||||||
↓
|
↓
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
│ Domain Layer (Entities) │
|
│ Infrastructure.Sql, Infrastructure.Jwt, Infrastructure.PasswordHashing, │
|
||||||
│ - UserAccount, UserCredential │
|
│ Infrastructure.Email, Infrastructure.Email.Templates │
|
||||||
│ - 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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
Include="FluentValidation.AspNetCore"
|
Include="FluentValidation.AspNetCore"
|
||||||
Version="11.3.0"
|
Version="11.3.0"
|
||||||
/>
|
/>
|
||||||
|
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -26,13 +27,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>
|
||||||
|
|||||||
@@ -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 System.Text.Json;
|
||||||
using API.Core.Contracts.Common;
|
using Shared.Contracts;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
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,6 +25,20 @@ 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.
|
||||||
@@ -73,6 +96,11 @@ public class JwtAuthenticationHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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";
|
||||||
@@ -83,4 +111,8 @@ public class JwtAuthenticationHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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 { }
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,22 @@ 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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using API.Core.Contracts.Common;
|
using Shared.Contracts;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||||
|
|
||||||
using API.Core.Contracts.Common;
|
using Shared.Contracts;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
@@ -9,9 +9,32 @@ using Microsoft.AspNetCore.Mvc.Filters;
|
|||||||
|
|
||||||
namespace API.Core;
|
namespace API.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
||||||
|
/// consistent JSON error responses with appropriate HTTP status codes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
|
||||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||||
: IExceptionFilter
|
: 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");
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
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);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -20,7 +20,10 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
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 +31,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();
|
||||||
|
|
||||||
@@ -48,21 +66,14 @@ builder.Services.AddSingleton<
|
|||||||
DefaultSqlConnectionFactory
|
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>();
|
||||||
|
|||||||
@@ -42,5 +42,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
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(
|
||||||
UserAccount createdUser,
|
string firstName,
|
||||||
|
string email,
|
||||||
string confirmationToken
|
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 +28,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 +54,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; }
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
using System.Collections.Generic;
|
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.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Service.Emails;
|
|
||||||
|
|
||||||
namespace API.Specs
|
namespace API.Specs
|
||||||
{
|
{
|
||||||
@@ -29,17 +29,17 @@ namespace API.Specs
|
|||||||
|
|
||||||
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 =>
|
var emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
||||||
d.ServiceType == typeof(IEmailService)
|
d.ServiceType == typeof(IEmailDispatcher)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (emailServiceDescriptor != null)
|
if (emailDispatcherDescriptor != null)
|
||||||
{
|
{
|
||||||
services.Remove(emailServiceDescriptor);
|
services.Remove(emailDispatcherDescriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
services.AddScoped<IEmailService, MockEmailService>();
|
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,8 +5,27 @@ 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>
|
||||||
|
/// 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")
|
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
@@ -38,9 +57,17 @@ public static class Program
|
|||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
||||||
private static readonly string connectionString = BuildConnectionString();
|
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");
|
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
||||||
|
/// database using DbUp, logging progress to the console.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
||||||
private static bool DeployMigrations()
|
private static bool DeployMigrations()
|
||||||
{
|
{
|
||||||
var upgrader = DeployChanges
|
var upgrader = DeployChanges
|
||||||
@@ -53,6 +80,14 @@ public static class Program
|
|||||||
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);
|
var myConn = new SqlConnection(masterConnectionString);
|
||||||
@@ -104,6 +139,12 @@ public static class Program
|
|||||||
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);
|
var myConn = new SqlConnection(masterConnectionString);
|
||||||
@@ -134,6 +175,13 @@ public static class Program
|
|||||||
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...");
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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
|
||||||
@@ -19,7 +19,5 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,18 @@ 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
|
||||||
{
|
{
|
||||||
|
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
|
||||||
private static readonly IReadOnlyList<(
|
private static readonly IReadOnlyList<(
|
||||||
string CountryName,
|
string CountryName,
|
||||||
string CountryCode
|
string CountryCode
|
||||||
@@ -15,6 +25,10 @@ internal class LocationSeeder : ISeeder
|
|||||||
("United States", "US"),
|
("United States", "US"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
|
||||||
|
/// and the ISO 3166-1 code of its parent country.
|
||||||
|
/// </summary>
|
||||||
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
@@ -123,6 +137,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,6 +258,12 @@ 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 (var (countryName, countryCode) in Countries)
|
||||||
@@ -265,6 +289,11 @@ internal class LocationSeeder : ISeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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,
|
||||||
@@ -282,6 +311,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,
|
||||||
@@ -304,6 +339,11 @@ internal class LocationSeeder : ISeeder
|
|||||||
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,
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ using DbUp;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Database.Seed;
|
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")
|
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ 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
|
||||||
{
|
{
|
||||||
|
/// <summary>The first/last name pairs used to generate seed user accounts.</summary>
|
||||||
private static readonly IReadOnlyList<(
|
private static readonly IReadOnlyList<(
|
||||||
string FirstName,
|
string FirstName,
|
||||||
string LastName
|
string LastName
|
||||||
@@ -116,6 +126,14 @@ 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();
|
var generator = new PasswordGenerator();
|
||||||
@@ -184,6 +202,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,
|
||||||
@@ -211,6 +241,13 @@ internal class UserSeeder : ISeeder
|
|||||||
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);
|
||||||
@@ -227,6 +264,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
|
||||||
@@ -243,6 +284,10 @@ internal class UserSeeder : ISeeder
|
|||||||
return result is not null;
|
return result is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a user verification record by invoking the <c>dbo.USP_CreateUserVerification</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="userAccountId">The identifier of the user account to verify.</param>
|
||||||
|
/// <returns>A task that completes when the verification record has been created.</returns>
|
||||||
private static async Task AddUserVerificationAsync(
|
private static async Task AddUserVerificationAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
@@ -258,6 +303,12 @@ internal class UserSeeder : ISeeder
|
|||||||
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);
|
||||||
|
|||||||
15
web/backend/Dockerfile.tests
Normal file
15
web/backend/Dockerfile.tests
Normal 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"]
|
||||||
@@ -1,13 +1,48 @@
|
|||||||
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,48 @@
|
|||||||
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Auth.Commands.ConfirmUser;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Commands;
|
||||||
|
|
||||||
|
public class ConfirmUserHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly ConfirmUserHandler _handler;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||||
|
return new ValidatedToken(userId, username, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string confirmationToken = "valid-confirmation-token";
|
||||||
|
|
||||||
|
var validatedToken = MakeValidatedToken(userId, username);
|
||||||
|
var userAccount = new UserAccount { UserAccountId = userId, Username = username };
|
||||||
|
|
||||||
|
_tokenServiceMock.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken)).ReturnsAsync(validatedToken);
|
||||||
|
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
|
||||||
|
|
||||||
|
var 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"));
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
var 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);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new ConfirmUserCommand(confirmationToken), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Auth.Commands.RefreshToken;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Commands;
|
||||||
|
|
||||||
|
public class RefreshTokenHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
|
||||||
|
{
|
||||||
|
var tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
var handler = new RefreshTokenHandler(tokenServiceMock.Object);
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var user = new UserAccount { UserAccountId = userId, Username = "testuser" };
|
||||||
|
|
||||||
|
tokenServiceMock
|
||||||
|
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
||||||
|
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
|
||||||
|
|
||||||
|
var 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Auth.Commands.RegisterUser;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
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 Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly Mock<IMediator> _mediatorMock;
|
||||||
|
private readonly RegisterUserHandler _handler;
|
||||||
|
|
||||||
|
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") =>
|
||||||
|
new(username, "John", "Doe", email, new DateTime(1990, 1, 1), "SecurePassword123!");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
||||||
|
{
|
||||||
|
var command = ValidCommand();
|
||||||
|
const string hashedPassword = "hashed_password_value";
|
||||||
|
var 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");
|
||||||
|
|
||||||
|
var 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()
|
||||||
|
{
|
||||||
|
var command = ValidCommand(username: "existinguser");
|
||||||
|
var existingUser = new UserAccount { UserAccountId = Guid.NewGuid(), Username = "existinguser" };
|
||||||
|
|
||||||
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(command.Username)).ReturnsAsync(existingUser);
|
||||||
|
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
var 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()
|
||||||
|
{
|
||||||
|
var command = ValidCommand(email: "existing@example.com");
|
||||||
|
var existingUser = new UserAccount { 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);
|
||||||
|
|
||||||
|
var 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()
|
||||||
|
{
|
||||||
|
var 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()
|
||||||
|
{
|
||||||
|
var 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"));
|
||||||
|
|
||||||
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ConfirmationEmailSent.Should().BeFalse();
|
||||||
|
result.AccessToken.Should().Be("access-token");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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 Mock<ITokenService> _tokenServiceMock = new();
|
||||||
|
private readonly Mock<IMediator> _mediatorMock = new();
|
||||||
|
private readonly ResendConfirmationEmailHandler _handler;
|
||||||
|
|
||||||
|
public ResendConfirmationEmailHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new ResendConfirmationEmailHandler(_authRepositoryMock.Object, _tokenServiceMock.Object, _mediatorMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var user = new UserAccount { 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()
|
||||||
|
{
|
||||||
|
var 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()
|
||||||
|
{
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var user = new UserAccount { 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,16 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<RootNamespace>Service.Breweries.Tests</RootNamespace>
|
<RootNamespace>Features.Auth.Tests</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.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="FluentAssertions" Version="6.9.0" />
|
||||||
|
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -19,10 +21,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Service.Breweries\Service.Breweries.csproj" />
|
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
|
||||||
<ProjectReference Include="..\Service.Auth\Service.Auth.csproj" />
|
|
||||||
<ProjectReference
|
|
||||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
|
||||||
<ProjectReference Include="..\..\API\API.Core\API.Core.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Auth.Queries.Login;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using Infrastructure.PasswordHashing;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Queries;
|
||||||
|
|
||||||
|
public class LoginHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||||
|
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly LoginHandler _handler;
|
||||||
|
|
||||||
|
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";
|
||||||
|
var userAccountId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var userAccount = new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountId = userAccountId,
|
||||||
|
Username = username,
|
||||||
|
FirstName = "René",
|
||||||
|
LastName = "Descartes",
|
||||||
|
Email = "r.descartes@example.com",
|
||||||
|
DateOfBirth = new DateTime(1596, 03, 31),
|
||||||
|
};
|
||||||
|
|
||||||
|
var userCredential = new UserCredential
|
||||||
|
{
|
||||||
|
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");
|
||||||
|
|
||||||
|
var 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);
|
||||||
|
|
||||||
|
var 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";
|
||||||
|
var userAccountId = Guid.NewGuid();
|
||||||
|
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
|
||||||
|
|
||||||
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||||
|
_authRepoMock.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId)).ReturnsAsync((UserCredential?)null);
|
||||||
|
|
||||||
|
var 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";
|
||||||
|
var userAccountId = Guid.NewGuid();
|
||||||
|
var userAccount = new UserAccount { UserAccountId = userAccountId, Username = username };
|
||||||
|
var userCredential = new UserCredential { 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);
|
||||||
|
|
||||||
|
var act = async () => await _handler.Handle(new LoginQuery(username, "wrong-password"), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedException>().WithMessage("Invalid username or password.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
using Apps72.Dev.Data.DbMocker;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Repository.Auth;
|
using Features.Auth.Repository;
|
||||||
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));
|
new(new TestConnectionFactory(conn));
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@@ -3,19 +3,20 @@ using System.Security.Claims;
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Infrastructure.Repository.Auth;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Service.Auth.Tests;
|
namespace Features.Auth.Tests.Services;
|
||||||
|
|
||||||
public class TokenServiceRefreshTest
|
public class TokenServiceRefreshTests
|
||||||
{
|
{
|
||||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
private readonly TokenService _tokenService;
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
public TokenServiceRefreshTest()
|
public TokenServiceRefreshTests()
|
||||||
{
|
{
|
||||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
@@ -3,19 +3,20 @@ using System.Security.Claims;
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Infrastructure.Repository.Auth;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Service.Auth.Tests;
|
namespace Features.Auth.Tests.Services;
|
||||||
|
|
||||||
public class TokenServiceValidationTest
|
public class TokenServiceValidationTests
|
||||||
{
|
{
|
||||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
private readonly TokenService _tokenService;
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
public TokenServiceValidationTest()
|
public TokenServiceValidationTests()
|
||||||
{
|
{
|
||||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
@@ -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>;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(request.Token);
|
||||||
|
|
||||||
|
var user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException("User account not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||||
|
return new LoginPayload(result.UserAccount.UserAccountId, result.UserAccount.Username, result.RefreshToken, result.AccessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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>;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
var hashed = passwordInfrastructure.Hash(request.Password);
|
||||||
|
|
||||||
|
var createdUser = await authRepo.RegisterUserAsync(
|
||||||
|
request.Username,
|
||||||
|
request.FirstName,
|
||||||
|
request.LastName,
|
||||||
|
request.Email,
|
||||||
|
request.DateOfBirth,
|
||||||
|
hashed
|
||||||
|
);
|
||||||
|
|
||||||
|
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||||
|
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||||
|
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||||
|
{
|
||||||
|
return new RegistrationPayload(createdUser.UserAccountId, createdUser.Username, string.Empty, string.Empty, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var 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)
|
||||||
|
{
|
||||||
|
var existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||||
|
var existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||||
|
|
||||||
|
if (existingUsername != null || existingEmail != null)
|
||||||
|
{
|
||||||
|
throw new ConflictException("Username or email already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||||
|
await mediator.Send(
|
||||||
|
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
web/backend/Features/Features.Auth/Controllers/AuthController.cs
Normal file
129
web/backend/Features/Features.Auth/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var 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)
|
||||||
|
{
|
||||||
|
var 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)
|
||||||
|
{
|
||||||
|
var 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
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var payload = await mediator.Send(command);
|
||||||
|
return Ok(new ResponseBody<LoginPayload>
|
||||||
|
{
|
||||||
|
Message = "Token refreshed successfully.",
|
||||||
|
Payload = payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs
Normal file
38
web/backend/Features/Features.Auth/Dtos/AuthPayloads.cs
Normal 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);
|
||||||
22
web/backend/Features/Features.Auth/Features.Auth.csproj
Normal file
22
web/backend/Features/Features.Auth/Features.Auth.csproj
Normal 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>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
var user =
|
||||||
|
await authRepo.GetUserByUsernameAsync(request.Username)
|
||||||
|
?? throw new UnauthorizedException("Invalid username or password.");
|
||||||
|
|
||||||
|
// @todo handle expired passwords
|
||||||
|
var 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.");
|
||||||
|
|
||||||
|
var accessToken = tokenService.GenerateAccessToken(user);
|
||||||
|
var refreshToken = tokenService.GenerateRefreshToken(user);
|
||||||
|
|
||||||
|
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,39 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Infrastructure.Repository.Sql;
|
using Infrastructure.Sql;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Auth;
|
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)
|
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||||
: Repository<Domain.Entities.UserAccount>(connectionFactory),
|
: Infrastructure.Sql.Repository<Domain.Entities.UserAccount>(connectionFactory),
|
||||||
IAuthRepository
|
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<Domain.Entities.UserAccount> RegisterUserAsync(
|
public async Task<Domain.Entities.UserAccount> RegisterUserAsync(
|
||||||
string username,
|
string username,
|
||||||
string firstName,
|
string firstName,
|
||||||
@@ -68,6 +92,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId) ?? throw new Exception("Failed to retrieve newly registered user.");
|
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<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
public async Task<Domain.Entities.UserAccount?> GetUserByEmailAsync(
|
||||||
string email
|
string email
|
||||||
)
|
)
|
||||||
@@ -83,6 +114,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
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<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
public async Task<Domain.Entities.UserAccount?> GetUserByUsernameAsync(
|
||||||
string username
|
string username
|
||||||
)
|
)
|
||||||
@@ -98,6 +136,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
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(
|
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
@@ -113,6 +158,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToCredentialEntity(reader) : null;
|
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(
|
public async Task RotateCredentialAsync(
|
||||||
Guid userAccountId,
|
Guid userAccountId,
|
||||||
string newPasswordHash
|
string newPasswordHash
|
||||||
@@ -129,6 +181,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
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<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
@@ -144,6 +202,15 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
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<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
||||||
Guid userAccountId
|
Guid userAccountId
|
||||||
)
|
)
|
||||||
@@ -180,6 +247,13 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return await GetUserByIdAsync(userAccountId);
|
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)
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using var connection = await CreateConnection();
|
await using var connection = await CreateConnection();
|
||||||
@@ -194,6 +268,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return result != null && result != DBNull.Value;
|
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)
|
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||||
{
|
{
|
||||||
// 2601/2627 are duplicate key violations in SQL Server.
|
// 2601/2627 are duplicate key violations in SQL Server.
|
||||||
@@ -204,6 +284,8 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserAccount entity.
|
/// Maps a data reader row to a UserAccount entity.
|
||||||
/// </summary>
|
/// </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 Domain.Entities.UserAccount MapToEntity(
|
protected override Domain.Entities.UserAccount MapToEntity(
|
||||||
DbDataReader reader
|
DbDataReader reader
|
||||||
)
|
)
|
||||||
@@ -227,8 +309,11 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserCredential entity.
|
/// 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>
|
/// </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)
|
private static UserCredential MapToCredentialEntity(DbDataReader reader)
|
||||||
{
|
{
|
||||||
var entity = new UserCredential
|
var entity = new UserCredential
|
||||||
@@ -265,8 +350,12 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper method to add a parameter to a database command.
|
/// Helper method to add a parameter to a database command, converting <c>null</c> values to
|
||||||
|
/// <see cref="DBNull.Value"/>.
|
||||||
/// </summary>
|
/// </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(
|
private static void AddParameter(
|
||||||
DbCommand command,
|
DbCommand command,
|
||||||
string name,
|
string name,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Auth;
|
namespace Features.Auth.Repository;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repository for authentication-related database operations including user registration and credential management.
|
/// Repository for authentication-related database operations including user registration and credential management.
|
||||||
@@ -65,8 +65,7 @@ public interface IAuthRepository
|
|||||||
/// Marks a user account as confirmed.
|
/// Marks a user account as confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userAccountId">ID of the user account to confirm</param>
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||||
/// <returns>The confirmed UserAccount entity</returns>
|
/// <returns>The confirmed UserAccount entity, or null if the user account does not exist</returns>
|
||||||
/// <exception cref="UnauthorizedException">If user account not found</exception>
|
|
||||||
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
113
web/backend/Features/Features.Auth/Services/ITokenService.cs
Normal file
113
web/backend/Features/Features.Auth/Services/ITokenService.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
namespace Features.Auth.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the kind of token being generated or validated.
|
||||||
|
/// </summary>
|
||||||
|
public enum TokenType
|
||||||
|
{
|
||||||
|
/// <summary>A short-lived token used to authorize API requests.</summary>
|
||||||
|
AccessToken,
|
||||||
|
/// <summary>A long-lived token used to obtain new access tokens.</summary>
|
||||||
|
RefreshToken,
|
||||||
|
/// <summary>A short-lived token used to confirm a user's email/account.</summary>
|
||||||
|
ConfirmationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of successfully validating a token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserId">The unique identifier of the user the token belongs to.</param>
|
||||||
|
/// <param name="Username">The username extracted from the token's claims.</param>
|
||||||
|
/// <param name="Principal">The <see cref="ClaimsPrincipal"/> produced from the validated token.</param>
|
||||||
|
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of refreshing a user's session.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="UserAccount">The user account associated with the refreshed session.</param>
|
||||||
|
/// <param name="RefreshToken">The newly issued refresh token.</param>
|
||||||
|
/// <param name="AccessToken">The newly issued access token.</param>
|
||||||
|
public record RefreshTokenResult(
|
||||||
|
UserAccount UserAccount,
|
||||||
|
string RefreshToken,
|
||||||
|
string AccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class TokenServiceExpirationHours
|
||||||
|
{
|
||||||
|
/// <summary>The expiration window, in hours, for access tokens.</summary>
|
||||||
|
public const double AccessTokenHours = 1;
|
||||||
|
/// <summary>The expiration window, in hours, for refresh tokens (21 days).</summary>
|
||||||
|
public const double RefreshTokenHours = 504; // 21 days
|
||||||
|
/// <summary>The expiration window, in hours, for confirmation tokens (30 minutes).</summary>
|
||||||
|
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines operations for generating and validating JWTs used for access, refresh, and account confirmation.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITokenService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new access token for the given user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed access token string.</returns>
|
||||||
|
string GenerateAccessToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new refresh token for the given user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed refresh token string.</returns>
|
||||||
|
string GenerateRefreshToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new confirmation token for the given user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed confirmation token string.</returns>
|
||||||
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a token of the type specified by <typeparamref name="T"/>, which must be <see cref="TokenType"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||||
|
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The access token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a refresh token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The refresh token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a confirmation token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The confirmation token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a refresh token and issues a new access/refresh token pair for the associated user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||||
|
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
||||||
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
|
}
|
||||||
210
web/backend/Features/Features.Auth/Services/TokenService.cs
Normal file
210
web/backend/Features/Features.Auth/Services/TokenService.cs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
|
||||||
|
namespace Features.Auth.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default implementation of <see cref="ITokenService"/> that generates and validates JWTs
|
||||||
|
/// for access, refresh, and confirmation flows using secrets read from environment variables.
|
||||||
|
/// </summary>
|
||||||
|
public class TokenService : ITokenService
|
||||||
|
{
|
||||||
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
|
private readonly IAuthRepository _authRepository;
|
||||||
|
|
||||||
|
private readonly string _accessTokenSecret;
|
||||||
|
private readonly string _refreshTokenSecret;
|
||||||
|
private readonly string _confirmationTokenSecret;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="TokenService"/>, loading the access, refresh, and
|
||||||
|
/// confirmation token signing secrets from environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokenInfrastructure">The infrastructure component used to generate and validate JWTs.</param>
|
||||||
|
/// <param name="authRepository">Repository used to look up user accounts during token refresh.</param>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||||
|
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||||
|
/// </exception>
|
||||||
|
public TokenService(
|
||||||
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
|
IAuthRepository authRepository
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_tokenInfrastructure = tokenInfrastructure;
|
||||||
|
_authRepository = authRepository;
|
||||||
|
|
||||||
|
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||||
|
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
||||||
|
|
||||||
|
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||||
|
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
||||||
|
|
||||||
|
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||||
|
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates an access token for the given user, signed with the access token secret and
|
||||||
|
/// expiring after <see cref="TokenServiceExpirationHours.AccessTokenHours"/> hours.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed access token string.</returns>
|
||||||
|
public string GenerateAccessToken(UserAccount user)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a refresh token for the given user, signed with the refresh token secret and
|
||||||
|
/// expiring after <see cref="TokenServiceExpirationHours.RefreshTokenHours"/> hours.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed refresh token string.</returns>
|
||||||
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||||
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a confirmation token for the given user, signed with the confirmation token secret and
|
||||||
|
/// expiring after <see cref="TokenServiceExpirationHours.ConfirmationTokenHours"/> hours.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed confirmation token string.</returns>
|
||||||
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||||
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a token of the kind specified by <typeparamref name="T"/>, dispatching to
|
||||||
|
/// <see cref="GenerateAccessToken"/>, <see cref="GenerateRefreshToken"/>, or
|
||||||
|
/// <see cref="GenerateConfirmationToken"/> based on the corresponding <see cref="TokenType"/> value.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The enum type identifying which kind of token to generate. Must be <see cref="TokenType"/>.</typeparam>
|
||||||
|
/// <param name="user">The user account to generate the token for.</param>
|
||||||
|
/// <returns>The signed token string corresponding to the requested token type.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <typeparamref name="T"/> is not <see cref="TokenType"/> or does not resolve to a known token type.
|
||||||
|
/// </exception>
|
||||||
|
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||||
|
{
|
||||||
|
if (typeof(T) != typeof(TokenType))
|
||||||
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
|
var tokenTypeName = typeof(T).Name;
|
||||||
|
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
||||||
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
|
var tokenType = (TokenType)parsed;
|
||||||
|
return tokenType switch
|
||||||
|
{
|
||||||
|
TokenType.AccessToken => GenerateAccessToken(user),
|
||||||
|
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||||
|
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||||
|
_ => throw new InvalidOperationException("Invalid token type"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an access token against the access token secret.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The access token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a refresh token against the refresh token secret.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The refresh token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a confirmation token against the confirmation token secret.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The confirmation token string to validate.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the token is missing required claims, has a malformed user ID, or otherwise fails validation.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs the shared validation logic for access, refresh, and confirmation tokens:
|
||||||
|
/// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">The token string to validate.</param>
|
||||||
|
/// <param name="secret">The secret key to validate the token's signature against.</param>
|
||||||
|
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
|
||||||
|
/// <returns>A <see cref="ValidatedToken"/> describing the token's claims.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid"/>,
|
||||||
|
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
|
||||||
|
/// </exception>
|
||||||
|
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||||
|
|
||||||
|
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||||
|
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
||||||
|
|
||||||
|
if (!Guid.TryParse(userIdClaim, out var userId))
|
||||||
|
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||||
|
|
||||||
|
return new ValidatedToken(userId, usernameClaim, principal);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the given refresh token, looks up the associated user, and issues a fresh
|
||||||
|
/// access/refresh token pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="refreshTokenString">The refresh token string to validate and exchange.</param>
|
||||||
|
/// <returns>A <see cref="RefreshTokenResult"/> containing the user and newly issued tokens.</returns>
|
||||||
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">
|
||||||
|
/// Thrown when the refresh token is invalid, or when the user account it refers to no longer exists.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
|
{
|
||||||
|
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
|
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||||
|
if (user == null)
|
||||||
|
throw new UnauthorizedException("User account not found");
|
||||||
|
|
||||||
|
var newAccess = GenerateAccessToken(user);
|
||||||
|
var newRefresh = GenerateRefreshToken(user);
|
||||||
|
|
||||||
|
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Breweries.Commands.CreateBrewery;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Commands;
|
||||||
|
|
||||||
|
public class CreateBreweryHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
private readonly CreateBreweryHandler _handler;
|
||||||
|
|
||||||
|
public CreateBreweryHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new CreateBreweryHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateBreweryLocation ValidLocation() =>
|
||||||
|
new(
|
||||||
|
CityId: Guid.NewGuid(),
|
||||||
|
AddressLine1: "123 Main St",
|
||||||
|
AddressLine2: null,
|
||||||
|
PostalCode: "12345",
|
||||||
|
Coordinates: null
|
||||||
|
);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PersistsEntity_WithNewIdsAndCreatedAt()
|
||||||
|
{
|
||||||
|
var command = new CreateBreweryCommand(
|
||||||
|
PostedById: Guid.NewGuid(),
|
||||||
|
BreweryName: "MyBrew",
|
||||||
|
Description: "Desc",
|
||||||
|
Location: ValidLocation()
|
||||||
|
);
|
||||||
|
|
||||||
|
BreweryPost? persisted = null;
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
|
||||||
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
var after = DateTime.UtcNow;
|
||||||
|
|
||||||
|
persisted.Should().NotBeNull();
|
||||||
|
persisted!.BreweryPostId.Should().NotBe(Guid.Empty);
|
||||||
|
persisted.PostedById.Should().Be(command.PostedById);
|
||||||
|
persisted.BreweryName.Should().Be("MyBrew");
|
||||||
|
persisted.Description.Should().Be("Desc");
|
||||||
|
persisted.CreatedAt.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||||
|
persisted.UpdatedAt.Should().BeNull();
|
||||||
|
|
||||||
|
persisted.Location.Should().NotBeNull();
|
||||||
|
persisted.Location!.BreweryPostLocationId.Should().NotBe(Guid.Empty);
|
||||||
|
persisted.Location.BreweryPostLocationId.Should().NotBe(persisted.BreweryPostId);
|
||||||
|
persisted.Location.CityId.Should().Be(command.Location.CityId);
|
||||||
|
persisted.Location.AddressLine1.Should().Be(command.Location.AddressLine1);
|
||||||
|
persisted.Location.PostalCode.Should().Be(command.Location.PostalCode);
|
||||||
|
|
||||||
|
_repoMock.Verify(r => r.CreateAsync(It.IsAny<BreweryPost>()), Times.Once);
|
||||||
|
|
||||||
|
result.BreweryName.Should().Be("MyBrew");
|
||||||
|
result.Location.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Commands;
|
||||||
|
|
||||||
|
public class DeleteBreweryHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_DelegatesToRepository()
|
||||||
|
{
|
||||||
|
var repoMock = new Mock<IBreweryRepository>();
|
||||||
|
var handler = new DeleteBreweryHandler(repoMock.Object);
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
|
||||||
|
|
||||||
|
repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Commands;
|
||||||
|
|
||||||
|
public class UpdateBreweryHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
private readonly UpdateBreweryHandler _handler;
|
||||||
|
|
||||||
|
public UpdateBreweryHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new UpdateBreweryHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
|
||||||
|
{
|
||||||
|
var command = new UpdateBreweryCommand(
|
||||||
|
BreweryPostId: Guid.NewGuid(),
|
||||||
|
PostedById: Guid.NewGuid(),
|
||||||
|
BreweryName: "Renamed",
|
||||||
|
Description: "New description",
|
||||||
|
Location: null
|
||||||
|
);
|
||||||
|
|
||||||
|
BreweryPost? persisted = null;
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||||
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
var after = DateTime.UtcNow;
|
||||||
|
|
||||||
|
persisted.Should().NotBeNull();
|
||||||
|
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||||
|
persisted.PostedById.Should().Be(command.PostedById);
|
||||||
|
persisted.BreweryName.Should().Be("Renamed");
|
||||||
|
persisted.Description.Should().Be("New description");
|
||||||
|
persisted.UpdatedAt.Should().NotBeNull();
|
||||||
|
persisted.UpdatedAt!.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
|
||||||
|
{
|
||||||
|
var command = new UpdateBreweryCommand(
|
||||||
|
BreweryPostId: Guid.NewGuid(),
|
||||||
|
PostedById: Guid.NewGuid(),
|
||||||
|
BreweryName: "Name",
|
||||||
|
Description: "Description",
|
||||||
|
Location: null
|
||||||
|
);
|
||||||
|
|
||||||
|
BreweryPost? persisted = null;
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||||
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
persisted!.Location.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
|
||||||
|
{
|
||||||
|
var locationCommand = new UpdateBreweryLocation(
|
||||||
|
BreweryPostLocationId: Guid.NewGuid(),
|
||||||
|
CityId: Guid.NewGuid(),
|
||||||
|
AddressLine1: "456 Oak Ave",
|
||||||
|
AddressLine2: "Suite 2",
|
||||||
|
PostalCode: "54321",
|
||||||
|
Coordinates: null
|
||||||
|
);
|
||||||
|
var command = new UpdateBreweryCommand(
|
||||||
|
BreweryPostId: Guid.NewGuid(),
|
||||||
|
PostedById: Guid.NewGuid(),
|
||||||
|
BreweryName: "Name",
|
||||||
|
Description: "Description",
|
||||||
|
Location: locationCommand
|
||||||
|
);
|
||||||
|
|
||||||
|
BreweryPost? persisted = null;
|
||||||
|
_repoMock
|
||||||
|
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
|
||||||
|
.Callback<BreweryPost>(b => persisted = b)
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
persisted!.Location.Should().NotBeNull();
|
||||||
|
persisted.Location!.BreweryPostLocationId.Should().Be(locationCommand.BreweryPostLocationId);
|
||||||
|
persisted.Location.BreweryPostId.Should().Be(command.BreweryPostId);
|
||||||
|
persisted.Location.CityId.Should().Be(locationCommand.CityId);
|
||||||
|
persisted.Location.AddressLine1.Should().Be(locationCommand.AddressLine1);
|
||||||
|
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
|
||||||
|
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Breweries.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.Breweries\Features.Breweries.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Queries;
|
||||||
|
|
||||||
|
public class GetAllBreweriesHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
private readonly GetAllBreweriesHandler _handler;
|
||||||
|
|
||||||
|
public GetAllBreweriesHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetAllBreweriesHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||||
|
{
|
||||||
|
_repoMock.Setup(r => r.GetAllAsync(10, 5))
|
||||||
|
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetAllBreweriesQuery(10, 5), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsAllBreweries_FromRepository()
|
||||||
|
{
|
||||||
|
var breweries = new[]
|
||||||
|
{
|
||||||
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" },
|
||||||
|
new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" },
|
||||||
|
};
|
||||||
|
_repoMock.Setup(r => r.GetAllAsync(null, null))
|
||||||
|
.ReturnsAsync(breweries);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetAllBreweriesQuery(null, null), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Select(b => b.BreweryPostId).Should().BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Features.Breweries.Queries.GetBreweryById;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Queries;
|
||||||
|
|
||||||
|
public class GetBreweryByIdHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IBreweryRepository> _repoMock = new();
|
||||||
|
private readonly GetBreweryByIdHandler _handler;
|
||||||
|
|
||||||
|
public GetBreweryByIdHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetBreweryByIdHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsBrewery_WhenFound()
|
||||||
|
{
|
||||||
|
var brewery = new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Test" };
|
||||||
|
_repoMock.Setup(r => r.GetByIdAsync(brewery.BreweryPostId))
|
||||||
|
.ReturnsAsync(brewery);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetBreweryByIdQuery(brewery.BreweryPostId), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.BreweryPostId.Should().Be(brewery.BreweryPostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsNull_WhenNotFound()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
_repoMock.Setup(r => r.GetByIdAsync(id))
|
||||||
|
.ReturnsAsync((BreweryPost?)null);
|
||||||
|
|
||||||
|
var result = await _handler.Handle(new GetBreweryByIdQuery(id), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
using Apps72.Dev.Data.DbMocker;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Repository.Breweries;
|
using Features.Breweries.Repository;
|
||||||
using Infrastructure.Repository.Tests.Database;
|
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Tests.Breweries;
|
namespace Features.Breweries.Tests.Repository;
|
||||||
|
|
||||||
public class BreweryRepositoryTest
|
public class BreweryRepositoryTests
|
||||||
{
|
{
|
||||||
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
|
private static BreweryRepository CreateRepo(MockDbConnection conn) =>
|
||||||
new(new TestConnectionFactory(conn));
|
new(new TestConnectionFactory(conn));
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Data.Common;
|
||||||
|
using Infrastructure.Sql;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Tests.Repository;
|
||||||
|
|
||||||
|
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||||
|
{
|
||||||
|
private readonly DbConnection _conn = conn;
|
||||||
|
|
||||||
|
public DbConnection CreateConnection() => _conn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Location data required to create a new brewery post, supplied as part of <see cref="CreateBreweryCommand"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateBreweryLocation(
|
||||||
|
Guid CityId,
|
||||||
|
string AddressLine1,
|
||||||
|
string? AddressLine2,
|
||||||
|
string PostalCode,
|
||||||
|
byte[]? Coordinates
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post. Bound directly from the request body of <c>POST /api/brewery</c>.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateBreweryCommand(
|
||||||
|
Guid PostedById,
|
||||||
|
string BreweryName,
|
||||||
|
string Description,
|
||||||
|
CreateBreweryLocation Location
|
||||||
|
) : IRequest<BreweryDto>;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="CreateBreweryCommand"/> by persisting a new brewery post and its location.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Repository used to persist the new brewery post.</param>
|
||||||
|
public class CreateBreweryHandler(IBreweryRepository repository)
|
||||||
|
: IRequestHandler<CreateBreweryCommand, BreweryDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post, generating new identifiers for the post and its location.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The details of the brewery post to create.</param>
|
||||||
|
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||||
|
/// <returns>The newly created brewery post.</returns>
|
||||||
|
public async Task<BreweryDto> Handle(CreateBreweryCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = new BreweryPost
|
||||||
|
{
|
||||||
|
BreweryPostId = Guid.NewGuid(),
|
||||||
|
PostedById = request.PostedById,
|
||||||
|
BreweryName = request.BreweryName,
|
||||||
|
Description = request.Description,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Location = new BreweryPostLocation
|
||||||
|
{
|
||||||
|
BreweryPostLocationId = Guid.NewGuid(),
|
||||||
|
CityId = request.Location.CityId,
|
||||||
|
AddressLine1 = request.Location.AddressLine1,
|
||||||
|
AddressLine2 = request.Location.AddressLine2,
|
||||||
|
PostalCode = request.Location.PostalCode,
|
||||||
|
Coordinates = request.Location.Coordinates,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await repository.CreateAsync(entity);
|
||||||
|
return entity.ToDto();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace API.Core.Contracts.Breweries;
|
namespace Features.Breweries.Commands.CreateBrewery;
|
||||||
|
|
||||||
public class BreweryCreateDtoValidator : AbstractValidator<BreweryCreateDto>
|
/// <summary>
|
||||||
|
/// Validates <see cref="CreateBreweryCommand"/> instances before they are processed.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateBreweryValidator : AbstractValidator<CreateBreweryCommand>
|
||||||
{
|
{
|
||||||
public BreweryCreateDtoValidator()
|
/// <summary>
|
||||||
|
/// Configures validation rules requiring <see cref="CreateBreweryCommand.PostedById"/>,
|
||||||
|
/// <see cref="CreateBreweryCommand.BreweryName"/>, <see cref="CreateBreweryCommand.Description"/>, and
|
||||||
|
/// <see cref="CreateBreweryCommand.Location"/> to be present, with length limits on the name, description,
|
||||||
|
/// address line 1, and postal code fields.
|
||||||
|
/// </summary>
|
||||||
|
public CreateBreweryValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.PostedById)
|
RuleFor(x => x.PostedById)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Features.Breweries.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="DeleteBreweryCommand"/> by deleting the matching brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Repository used to delete the brewery post.</param>
|
||||||
|
public class DeleteBreweryHandler(IBreweryRepository repository)
|
||||||
|
: IRequestHandler<DeleteBreweryCommand>
|
||||||
|
{
|
||||||
|
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken) =>
|
||||||
|
repository.DeleteAsync(request.BreweryPostId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Location data for an existing brewery post, supplied as part of <see cref="UpdateBreweryCommand"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateBreweryLocation(
|
||||||
|
Guid BreweryPostLocationId,
|
||||||
|
Guid CityId,
|
||||||
|
string AddressLine1,
|
||||||
|
string? AddressLine2,
|
||||||
|
string PostalCode,
|
||||||
|
byte[]? Coordinates
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
|
||||||
|
/// A <c>null</c> <see cref="Location"/> clears the brewery's location.
|
||||||
|
/// </summary>
|
||||||
|
public record UpdateBreweryCommand(
|
||||||
|
Guid BreweryPostId,
|
||||||
|
Guid PostedById,
|
||||||
|
string BreweryName,
|
||||||
|
string Description,
|
||||||
|
UpdateBreweryLocation? Location
|
||||||
|
) : IRequest<BreweryDto>;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="UpdateBreweryCommand"/> by persisting changes to an existing brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Repository used to persist the updated brewery post.</param>
|
||||||
|
public class UpdateBreweryHandler(IBreweryRepository repository)
|
||||||
|
: IRequestHandler<UpdateBreweryCommand, BreweryDto>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing brewery post. If <paramref name="request"/> has no <c>Location</c>,
|
||||||
|
/// the brewery's location is cleared.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The updated details of the brewery post.</param>
|
||||||
|
/// <param name="cancellationToken">A token to observe for cancellation requests.</param>
|
||||||
|
/// <returns>The updated brewery post.</returns>
|
||||||
|
public async Task<BreweryDto> Handle(UpdateBreweryCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = new BreweryPost
|
||||||
|
{
|
||||||
|
BreweryPostId = request.BreweryPostId,
|
||||||
|
PostedById = request.PostedById,
|
||||||
|
BreweryName = request.BreweryName,
|
||||||
|
Description = request.Description,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
Location = request.Location is null ? null : new BreweryPostLocation
|
||||||
|
{
|
||||||
|
BreweryPostLocationId = request.Location.BreweryPostLocationId,
|
||||||
|
BreweryPostId = request.BreweryPostId,
|
||||||
|
CityId = request.Location.CityId,
|
||||||
|
AddressLine1 = request.Location.AddressLine1,
|
||||||
|
AddressLine2 = request.Location.AddressLine2,
|
||||||
|
PostalCode = request.Location.PostalCode,
|
||||||
|
Coordinates = request.Location.Coordinates,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await repository.UpdateAsync(entity);
|
||||||
|
return entity.ToDto();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using Features.Breweries.Commands.CreateBrewery;
|
||||||
|
using Features.Breweries.Commands.DeleteBrewery;
|
||||||
|
using Features.Breweries.Commands.UpdateBrewery;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
using Features.Breweries.Queries.GetBreweryById;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Shared.Contracts;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides CRUD endpoints for managing brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default; read endpoints
|
||||||
|
/// (<see cref="GetById"/> and <see cref="GetAll"/>) opt out via <c>[AllowAnonymous]</c>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="mediator">Used to dispatch brewery commands and queries to their handlers.</param>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
|
public class BreweryController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a single brewery post by its unique identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to retrieve.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of <see cref="BreweryDto"/> if found;
|
||||||
|
/// otherwise a <c>404 Not Found</c> result wrapping a <see cref="ResponseBody"/> error message.
|
||||||
|
/// </returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var brewery = await mediator.Send(new GetBreweryByIdQuery(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 = brewery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a paginated list of brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
|
/// <param name="limit">The maximum number of brewery posts to return, or <c>null</c> for no limit.</param>
|
||||||
|
/// <param name="offset">The number of brewery posts to skip before returning results, or <c>null</c> for no offset.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of a collection of <see cref="BreweryDto"/>.</returns>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryDto>>>> GetAll(
|
||||||
|
[FromQuery] int? limit,
|
||||||
|
[FromQuery] int? offset)
|
||||||
|
{
|
||||||
|
var breweries = await mediator.Send(new GetAllBreweriesQuery(limit, offset));
|
||||||
|
return Ok(new ResponseBody<IEnumerable<BreweryDto>>
|
||||||
|
{
|
||||||
|
Message = "Breweries retrieved successfully.",
|
||||||
|
Payload = breweries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command">The brewery details to create, including the posting user, name, description, and location.</param>
|
||||||
|
/// <returns>A <c>201 Created</c> result wrapping a <see cref="ResponseBody{T}"/> of the newly created <see cref="BreweryDto"/>.</returns>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create([FromBody] CreateBreweryCommand command)
|
||||||
|
{
|
||||||
|
var brewery = await mediator.Send(command);
|
||||||
|
return Created($"/api/brewery/{brewery.BreweryPostId}", new ResponseBody<BreweryDto>
|
||||||
|
{
|
||||||
|
Message = "Brewery created successfully.",
|
||||||
|
Payload = brewery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an existing brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post from the route, which must match <paramref name="command"/>'s ID.</param>
|
||||||
|
/// <param name="command">The updated brewery details, including the posting user, name, description, and optional location.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}"/> of the updated <see cref="BreweryDto"/> if the
|
||||||
|
/// update succeeds; otherwise a <c>400 Bad Request</c> result wrapping a <see cref="ResponseBody"/> error message
|
||||||
|
/// when the route ID does not match the payload ID.
|
||||||
|
/// </returns>
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(Guid id, [FromBody] UpdateBreweryCommand command)
|
||||||
|
{
|
||||||
|
if (command.BreweryPostId != id)
|
||||||
|
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
|
||||||
|
|
||||||
|
var brewery = await mediator.Send(command);
|
||||||
|
return Ok(new ResponseBody<BreweryDto>
|
||||||
|
{
|
||||||
|
Message = "Brewery updated successfully.",
|
||||||
|
Payload = brewery,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The unique identifier of the brewery post to delete.</param>
|
||||||
|
/// <returns>A <c>200 OK</c> result wrapping a <see cref="ResponseBody"/> confirming the deletion.</returns>
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeleteBreweryCommand(id));
|
||||||
|
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Features.Breweries.Repository;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Features.Breweries.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the services owned by the Breweries feature slice.
|
||||||
|
/// </summary>
|
||||||
|
public static class FeaturesBreweriesServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddFeaturesBreweries(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IBreweryRepository, BreweryRepository>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs
Normal file
89
web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
namespace Features.Breweries.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the location details of an existing brewery, as returned in <see cref="BreweryDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class BreweryLocationDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the brewery's location record.
|
||||||
|
/// </summary>
|
||||||
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the brewery post that this location belongs to.
|
||||||
|
/// </summary>
|
||||||
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the city in which the brewery is located.
|
||||||
|
/// </summary>
|
||||||
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The primary street address line of the brewery.
|
||||||
|
/// </summary>
|
||||||
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An optional secondary address line (e.g. suite or unit number).
|
||||||
|
/// </summary>
|
||||||
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The postal/ZIP code of the brewery's address.
|
||||||
|
/// </summary>
|
||||||
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The optional geographic coordinates of the brewery, in a raw binary representation.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Coordinates { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a brewery post as returned by the brewery endpoints, including its metadata and
|
||||||
|
/// optional location.
|
||||||
|
/// </summary>
|
||||||
|
public class BreweryDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the brewery post.
|
||||||
|
/// </summary>
|
||||||
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The unique identifier of the user account that created the brewery post.
|
||||||
|
/// </summary>
|
||||||
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the brewery.
|
||||||
|
/// </summary>
|
||||||
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A description of the brewery.
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time at which the brewery post was created.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time at which the brewery post was last updated, or <c>null</c> if it has never been updated.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A row-version/concurrency token used to detect conflicting concurrent updates to the brewery post.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Timer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The location details of the brewery, or <c>null</c> if no location is associated with it.
|
||||||
|
/// </summary>
|
||||||
|
public BreweryLocationDto? Location { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Dtos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps <see cref="BreweryPost"/> domain entities to their <see cref="BreweryDto"/> wire representation.
|
||||||
|
/// </summary>
|
||||||
|
public static class BreweryDtoMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a <see cref="BreweryPost"/> domain entity to its <see cref="BreweryDto"/> representation,
|
||||||
|
/// including its location, if present.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="brewery">The brewery post entity to map.</param>
|
||||||
|
/// <returns>The mapped <see cref="BreweryDto"/>.</returns>
|
||||||
|
public static BreweryDto ToDto(this BreweryPost brewery) => new()
|
||||||
|
{
|
||||||
|
BreweryPostId = brewery.BreweryPostId,
|
||||||
|
PostedById = brewery.PostedById,
|
||||||
|
BreweryName = brewery.BreweryName,
|
||||||
|
Description = brewery.Description,
|
||||||
|
CreatedAt = brewery.CreatedAt,
|
||||||
|
UpdatedAt = brewery.UpdatedAt,
|
||||||
|
Timer = brewery.Timer,
|
||||||
|
Location = brewery.Location is null ? null : new BreweryLocationDto
|
||||||
|
{
|
||||||
|
BreweryPostLocationId = brewery.Location.BreweryPostLocationId,
|
||||||
|
BreweryPostId = brewery.Location.BreweryPostId,
|
||||||
|
CityId = brewery.Location.CityId,
|
||||||
|
AddressLine1 = brewery.Location.AddressLine1,
|
||||||
|
AddressLine2 = brewery.Location.AddressLine2,
|
||||||
|
PostalCode = brewery.Location.PostalCode,
|
||||||
|
Coordinates = brewery.Location.Coordinates,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Features.Breweries</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetAllBreweriesQuery"/> by retrieving a paginated list of brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Repository used to query brewery post data.</param>
|
||||||
|
public class GetAllBreweriesHandler(IBreweryRepository repository)
|
||||||
|
: IRequestHandler<GetAllBreweriesQuery, IEnumerable<BreweryDto>>
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<BreweryDto>> Handle(GetAllBreweriesQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var breweries = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||||
|
return breweries.Select(b => b.ToDto());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a paginated list of brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
public record GetAllBreweriesQuery(int? Limit, int? Offset) : IRequest<IEnumerable<BreweryDto>>;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetBreweryById;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetBreweryByIdQuery"/> by looking up the matching brewery post.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="repository">Repository used to query brewery post data.</param>
|
||||||
|
public class GetBreweryByIdHandler(IBreweryRepository repository)
|
||||||
|
: IRequestHandler<GetBreweryByIdQuery, BreweryDto?>
|
||||||
|
{
|
||||||
|
public async Task<BreweryDto?> Handle(GetBreweryByIdQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var brewery = await repository.GetByIdAsync(request.BreweryPostId);
|
||||||
|
return brewery?.ToDto();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user