mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 17:47:22 +00:00
Compare commits
32 Commits
main-2.0
...
ac97c03612
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac97c03612 | ||
|
|
460b0dca9a | ||
|
|
f7e3ace9c1 | ||
|
|
95f4fb8b91 | ||
|
|
1ebe17bf42 | ||
|
|
678ee21bbd | ||
|
|
3d046e369c | ||
|
|
034436d018 | ||
|
|
58833f330c | ||
|
|
bfda60d831 | ||
|
|
39b5d5bf1d | ||
|
|
e5d08c9ec8 | ||
|
|
1272a7ff40 | ||
|
|
0933ded022 | ||
|
|
a37f2971a7 | ||
|
|
9ac039dfeb | ||
|
|
1875dcc8bf | ||
|
|
c19af68c7c | ||
|
|
433c3fbe3d | ||
|
|
aadeab3afa | ||
|
|
0f77ae43b5 | ||
|
|
4de455f34d | ||
|
|
2bdd7bb0c0 | ||
|
|
5b882ac51c | ||
|
|
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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 80,
|
"printWidth": 100,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"indentSize": 4,
|
"indentSize": 4,
|
||||||
"endOfLine": "lf",
|
"endOfLine": "lf",
|
||||||
|
|||||||
@@ -8,15 +8,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
||||||
Include="Microsoft.AspNetCore.OpenApi"
|
|
||||||
Version="9.0.11"
|
|
||||||
/>
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
Include="FluentValidation.AspNetCore"
|
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||||
Version="11.3.0"
|
|
||||||
/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -26,13 +21,14 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.Breweries\Service.Breweries.csproj" />
|
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
|
||||||
using API.Core.Contracts.Common;
|
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Shared.Contracts;
|
||||||
|
|
||||||
namespace API.Core.Authentication;
|
namespace API.Core.Authentication;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom ASP.NET Core authentication handler that validates bearer JWT access tokens
|
||||||
|
/// supplied in the <c>Authorization</c> header against the <c>JWT</c> authentication scheme.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">The monitored options for the <c>JWT</c> authentication scheme.</param>
|
||||||
|
/// <param name="logger">The logger factory used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
|
||||||
|
/// <param name="encoder">The URL encoder used by the base <see cref="AuthenticationHandler{TOptions}" />.</param>
|
||||||
|
/// <param name="tokenInfrastructure">The infrastructure service used to validate JWT access tokens.</param>
|
||||||
|
/// <param name="configuration">The application configuration, used as a fallback source for the JWT secret key.</param>
|
||||||
public class JwtAuthenticationHandler(
|
public class JwtAuthenticationHandler(
|
||||||
IOptionsMonitor<JwtAuthenticationOptions> options,
|
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
@@ -16,71 +25,76 @@ public class JwtAuthenticationHandler(
|
|||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the incoming request's bearer JWT access token and produces an authentication result.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The signing secret is resolved first from the <c>ACCESS_TOKEN_SECRET</c> environment variable, falling
|
||||||
|
/// back to the <c>Jwt:SecretKey</c> configuration value, to stay consistent with the secret source used
|
||||||
|
/// when tokens are issued. Authentication fails if the secret is not configured, the
|
||||||
|
/// <c>Authorization</c> header is missing, the header does not use the <c>Bearer</c> scheme, or the
|
||||||
|
/// token fails validation.
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>
|
||||||
|
/// A successful <see cref="AuthenticateResult" /> containing the validated
|
||||||
|
/// <see cref="System.Security.Claims.ClaimsPrincipal" />
|
||||||
|
/// on success, or a failure result describing why authentication could not be completed.
|
||||||
|
/// </returns>
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||||
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
{
|
|
||||||
secret = configuration["Jwt:SecretKey"];
|
secret = configuration["Jwt:SecretKey"];
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(secret))
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
{
|
|
||||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Authorization header exists
|
// Check if Authorization header exists
|
||||||
if (
|
if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
|
||||||
!Request.Headers.TryGetValue(
|
|
||||||
"Authorization",
|
|
||||||
out var authHeaderValue
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return AuthenticateResult.Fail("Authorization header is missing");
|
return AuthenticateResult.Fail("Authorization header is missing");
|
||||||
}
|
|
||||||
|
|
||||||
var authHeader = authHeaderValue.ToString();
|
string authHeader = authHeaderValue.ToString();
|
||||||
if (
|
if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
!authHeader.StartsWith(
|
return AuthenticateResult.Fail("Invalid authorization header format");
|
||||||
"Bearer ",
|
|
||||||
StringComparison.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return AuthenticateResult.Fail(
|
|
||||||
"Invalid authorization header format"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
string token = authHeader.Substring("Bearer ".Length).Trim();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
ClaimsPrincipal claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||||
token,
|
token,
|
||||||
secret
|
secret
|
||||||
);
|
);
|
||||||
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
AuthenticationTicket ticket = new(claimsPrincipal, Scheme.Name);
|
||||||
return AuthenticateResult.Success(ticket);
|
return AuthenticateResult.Success(ticket);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return AuthenticateResult.Fail(
|
return AuthenticateResult.Fail($"Token validation failed: {ex.Message}");
|
||||||
$"Token validation failed: {ex.Message}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a JSON 401 Unauthorized response when authentication fails or is required but not supplied.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="properties">The authentication properties associated with the challenge.</param>
|
||||||
|
/// <returns>A task that completes once the response body has been written.</returns>
|
||||||
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
{
|
{
|
||||||
Response.ContentType = "application/json";
|
Response.ContentType = "application/json";
|
||||||
Response.StatusCode = 401;
|
Response.StatusCode = 401;
|
||||||
|
|
||||||
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
ResponseBody response = new()
|
||||||
|
{
|
||||||
|
Message = "Unauthorized: Invalid or missing authentication token",
|
||||||
|
};
|
||||||
await Response.WriteAsJsonAsync(response);
|
await Response.WriteAsJsonAsync(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Options for the <c>JWT</c> authentication scheme handled by <see cref="JwtAuthenticationHandler" />.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>No additional options are defined beyond those provided by <see cref="AuthenticationSchemeOptions" />.</remarks>
|
||||||
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace API.Core.Controllers
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles requests that do not match any other route, used as the application's fallback controller.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Excluded from API explorer/Swagger output via <c>[ApiExplorerSettings(IgnoreApi = true)]</c>.
|
||||||
|
/// Wired up as the fallback target via <c>app.MapFallbackToController("Handle404", "NotFound")</c> in
|
||||||
|
/// <c>Program.cs</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[ApiController]
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
|
[Route("error")] // required
|
||||||
|
public class NotFoundController : ControllerBase
|
||||||
{
|
{
|
||||||
[ApiController]
|
/// <summary>
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
/// Returns a generic 404 response for any request that did not match a defined route.
|
||||||
[Route("error")] // required
|
/// </summary>
|
||||||
public class NotFoundController : ControllerBase
|
/// <returns>A <c>404 Not Found</c> result with a JSON body containing a "Route not found." message.</returns>
|
||||||
|
[HttpGet("404")] //required
|
||||||
|
public IActionResult Handle404()
|
||||||
{
|
{
|
||||||
[HttpGet("404")] //required
|
return NotFound(new { message = "Route not found." });
|
||||||
public IActionResult Handle404()
|
|
||||||
{
|
|
||||||
return NotFound(new { message = "Route not found." });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using API.Core.Contracts.Common;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Shared.Contracts;
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sample controller demonstrating an endpoint that requires JWT authentication.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[Authorize(AuthenticationSchemes = "JWT")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class ProtectedController : ControllerBase
|
public class ProtectedController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the authenticated caller's user ID and username, extracted from the JWT claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// A <c>200 OK</c> result wrapping a <see cref="ResponseBody{T}" /> whose payload contains the
|
||||||
|
/// caller's <c>userId</c> (from <see cref="ClaimTypes.NameIdentifier" />) and <c>username</c>
|
||||||
|
/// (from <see cref="ClaimTypes.Name" />), either of which may be <c>null</c> if not present in the token.
|
||||||
|
/// </returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<ResponseBody<object>> Get()
|
public ActionResult<ResponseBody<object>> Get()
|
||||||
{
|
{
|
||||||
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<object>
|
new ResponseBody<object>
|
||||||
|
|||||||
@@ -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,30 +1,89 @@
|
|||||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||||
|
|
||||||
using API.Core.Contracts.Common;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentValidation;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Shared.Contracts;
|
||||||
|
using ValidationException = FluentValidation.ValidationException;
|
||||||
|
|
||||||
namespace API.Core;
|
namespace API.Core;
|
||||||
|
|
||||||
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
/// <summary>
|
||||||
: IExceptionFilter
|
/// MVC exception filter that converts unhandled exceptions raised by controller actions into
|
||||||
|
/// consistent JSON error responses with appropriate HTTP status codes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger used to record unhandled exceptions before they are translated into a response.</param>
|
||||||
|
public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger) : IExceptionFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Logs the exception and sets <see cref="ExceptionContext.Result" /> to an appropriate error response,
|
||||||
|
/// marking the exception as handled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Maps exception types to responses as follows:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="FluentValidation.ValidationException" /> → <c>400 Bad Request</c> with per-property
|
||||||
|
/// validation error messages.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="ConflictException" /> → <c>409 Conflict</c> with a <see cref="ResponseBody" />
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="NotFoundException" /> → <c>404 Not Found</c> with a <see cref="ResponseBody" />
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="UnauthorizedException" /> → <c>401 Unauthorized</c> with a
|
||||||
|
/// <see cref="ResponseBody" /> message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="ForbiddenException" /> → <c>403 Forbidden</c> with a <see cref="ResponseBody" />
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="SqlException" /> → <c>503 Service Unavailable</c> with a generic database error
|
||||||
|
/// message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>
|
||||||
|
/// <see cref="Domain.Exceptions.ValidationException" /> → <c>400 Bad Request</c> with a
|
||||||
|
/// <see cref="ResponseBody" /> message.
|
||||||
|
/// </description>
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <description>Any other exception → <c>500 Internal Server Error</c> with a generic error message.</description>
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="context">
|
||||||
|
/// The context for the unhandled exception, including the exception itself and the result to
|
||||||
|
/// populate.
|
||||||
|
/// </param>
|
||||||
public void OnException(ExceptionContext context)
|
public void OnException(ExceptionContext context)
|
||||||
{
|
{
|
||||||
logger.LogError(context.Exception, "Unhandled exception occurred");
|
logger.LogError(context.Exception, "Unhandled exception occurred");
|
||||||
|
|
||||||
switch (context.Exception)
|
switch (context.Exception)
|
||||||
{
|
{
|
||||||
case FluentValidation.ValidationException fluentValidationException:
|
case ValidationException fluentValidationException:
|
||||||
var errors = fluentValidationException
|
Dictionary<string, string[]> errors = fluentValidationException
|
||||||
.Errors.GroupBy(e => e.PropertyName)
|
.Errors.GroupBy(e => e.PropertyName)
|
||||||
.ToDictionary(
|
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
|
||||||
g => g.Key,
|
|
||||||
g => g.Select(e => e.ErrorMessage).ToArray()
|
|
||||||
);
|
|
||||||
|
|
||||||
context.Result = new BadRequestObjectResult(
|
context.Result = new BadRequestObjectResult(
|
||||||
new { message = "Validation failed", errors }
|
new { message = "Validation failed", errors }
|
||||||
@@ -33,9 +92,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case ConflictException ex:
|
case ConflictException ex:
|
||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||||
new ResponseBody { Message = ex.Message }
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
StatusCode = 409,
|
StatusCode = 409,
|
||||||
};
|
};
|
||||||
@@ -43,9 +100,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case NotFoundException ex:
|
case NotFoundException ex:
|
||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||||
new ResponseBody { Message = ex.Message }
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
StatusCode = 404,
|
StatusCode = 404,
|
||||||
};
|
};
|
||||||
@@ -53,9 +108,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case UnauthorizedException ex:
|
case UnauthorizedException ex:
|
||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||||
new ResponseBody { Message = ex.Message }
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
StatusCode = 401,
|
StatusCode = 401,
|
||||||
};
|
};
|
||||||
@@ -63,9 +116,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case ForbiddenException ex:
|
case ForbiddenException ex:
|
||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||||
new ResponseBody { Message = ex.Message }
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
StatusCode = 403,
|
StatusCode = 403,
|
||||||
};
|
};
|
||||||
@@ -83,9 +134,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case Domain.Exceptions.ValidationException ex:
|
case Domain.Exceptions.ValidationException ex:
|
||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(new ResponseBody { Message = ex.Message })
|
||||||
new ResponseBody { Message = ex.Message }
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
StatusCode = 400,
|
StatusCode = 400,
|
||||||
};
|
};
|
||||||
@@ -94,10 +143,7 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(
|
||||||
new ResponseBody
|
new ResponseBody { Message = "An unexpected error occurred" }
|
||||||
{
|
|
||||||
Message = "An unexpected error occurred",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
StatusCode = 500,
|
StatusCode = 500,
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
using API.Core;
|
using API.Core;
|
||||||
using API.Core.Authentication;
|
using API.Core.Authentication;
|
||||||
|
using Features.Auth.Controllers;
|
||||||
|
using Features.Auth.DependencyInjection;
|
||||||
|
using Features.Breweries.Controllers;
|
||||||
|
using Features.Breweries.DependencyInjection;
|
||||||
|
using Features.Emails.DependencyInjection;
|
||||||
|
using Features.Emails.Services;
|
||||||
|
using Features.UserManagement.Controllers;
|
||||||
|
using Features.UserManagement.DependencyInjection;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using FluentValidation.AspNetCore;
|
using FluentValidation.AspNetCore;
|
||||||
using Infrastructure.Email;
|
|
||||||
using Infrastructure.Email.Templates.Rendering;
|
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.Sql;
|
||||||
using Infrastructure.Repository.Auth;
|
using Shared.Application.Behaviors;
|
||||||
using Infrastructure.Repository.Sql;
|
|
||||||
using Infrastructure.Repository.UserAccount;
|
|
||||||
using Infrastructure.Repository.Breweries;
|
|
||||||
using Service.Auth;
|
|
||||||
using Service.Emails;
|
|
||||||
using Service.UserManagement.User;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Global Exception Filter
|
// Global Exception Filter
|
||||||
builder.Services.AddControllers(options =>
|
builder
|
||||||
{
|
.Services.AddControllers(options =>
|
||||||
options.Filters.Add<GlobalExceptionFilter>();
|
{
|
||||||
});
|
options.Filters.Add<GlobalExceptionFilter>();
|
||||||
|
})
|
||||||
|
.AddApplicationPart(typeof(BreweryController).Assembly)
|
||||||
|
.AddApplicationPart(typeof(UserController).Assembly)
|
||||||
|
.AddApplicationPart(typeof(AuthController).Assembly);
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
@@ -28,8 +32,23 @@ builder.Services.AddOpenApi();
|
|||||||
|
|
||||||
// Add FluentValidation
|
// Add FluentValidation
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||||
|
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
|
||||||
|
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
|
||||||
|
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
|
||||||
builder.Services.AddFluentValidationAutoValidation();
|
builder.Services.AddFluentValidationAutoValidation();
|
||||||
|
|
||||||
|
// Add MediatR. Each Features.* slice's assembly is registered here as it's introduced;
|
||||||
|
// ValidationBehavior runs FluentValidation validators in the MediatR pipeline for command/query handlers.
|
||||||
|
builder.Services.AddMediatR(cfg =>
|
||||||
|
{
|
||||||
|
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
|
||||||
|
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||||
|
});
|
||||||
|
|
||||||
// Add health checks
|
// Add health checks
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
@@ -37,32 +56,20 @@ builder.Services.AddHealthChecks();
|
|||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
builder.Logging.AddConsole();
|
builder.Logging.AddConsole();
|
||||||
if (!builder.Environment.IsProduction())
|
if (!builder.Environment.IsProduction())
|
||||||
{
|
|
||||||
builder.Logging.AddDebug();
|
builder.Logging.AddDebug();
|
||||||
}
|
|
||||||
|
|
||||||
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
builder.Services.AddSingleton<
|
builder.Services.AddSingleton<ISqlConnectionFactory, DefaultSqlConnectionFactory>();
|
||||||
ISqlConnectionFactory,
|
|
||||||
DefaultSqlConnectionFactory
|
|
||||||
>();
|
|
||||||
|
|
||||||
builder.Services.AddScoped<IUserAccountRepository, UserAccountRepository>();
|
builder.Services.AddFeaturesBreweries();
|
||||||
builder.Services.AddScoped<IAuthRepository, AuthRepository>();
|
builder.Services.AddFeaturesUserManagement();
|
||||||
builder.Services.AddScoped<IBreweryRepository, BreweryRepository>();
|
builder.Services.AddFeaturesAuth();
|
||||||
|
builder.Services.AddFeaturesEmails();
|
||||||
builder.Services.AddScoped<IUserService, UserService>();
|
|
||||||
builder.Services.AddScoped<ILoginService, LoginService>();
|
|
||||||
builder.Services.AddScoped<IRegisterService, RegisterService>();
|
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
|
||||||
|
|
||||||
|
// ITokenInfrastructure is registered here (not inside Features.Auth's own DI extension) because
|
||||||
|
// JwtAuthenticationHandler, a host-level auth middleware concern, also depends on it directly.
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||||
builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
|
||||||
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
|
||||||
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
|
||||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
|
||||||
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
|
|
||||||
|
|
||||||
// Register the exception filter
|
// Register the exception filter
|
||||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||||
@@ -70,14 +77,11 @@ builder.Services.AddScoped<GlobalExceptionFilter>();
|
|||||||
// Configure JWT Authentication
|
// Configure JWT Authentication
|
||||||
builder
|
builder
|
||||||
.Services.AddAuthentication("JWT")
|
.Services.AddAuthentication("JWT")
|
||||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
|
||||||
"JWT",
|
|
||||||
options => { }
|
|
||||||
);
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
@@ -95,7 +99,7 @@ app.MapControllers();
|
|||||||
app.MapFallbackToController("Handle404", "NotFound");
|
app.MapFallbackToController("Handle404", "NotFound");
|
||||||
|
|
||||||
// Graceful shutdown handling
|
// Graceful shutdown handling
|
||||||
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||||
lifetime.ApplicationStopping.Register(() =>
|
lifetime.ApplicationStopping.Register(() =>
|
||||||
{
|
{
|
||||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||||
|
|||||||
@@ -24,10 +24,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ASP.NET Core integration testing -->
|
<!-- ASP.NET Core integration testing -->
|
||||||
<PackageReference
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.1" />
|
||||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
|
||||||
Version="9.0.1"
|
|
||||||
/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -42,5 +39,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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,51 +1,51 @@
|
|||||||
Feature: Protected Endpoint Access Token Validation
|
Feature: Protected Endpoint Access Token Validation
|
||||||
As a backend developer
|
As a backend developer
|
||||||
I want protected endpoints to validate access tokens
|
I want protected endpoints to validate access tokens
|
||||||
So that unauthorized requests are rejected
|
So that unauthorized requests are rejected
|
||||||
|
|
||||||
Scenario: Protected endpoint accepts valid access token
|
Scenario: Protected endpoint accepts valid access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in
|
And I am logged in
|
||||||
When I submit a request to a protected endpoint with a valid access token
|
When I submit a request to a protected endpoint with a valid access token
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects missing access token
|
Scenario: Protected endpoint rejects missing access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a request to a protected endpoint without an access token
|
When I submit a request to a protected endpoint without an access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects invalid access token
|
Scenario: Protected endpoint rejects invalid access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a request to a protected endpoint with an invalid access token
|
When I submit a request to a protected endpoint with an invalid access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Unauthorized"
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects expired access token
|
Scenario: Protected endpoint rejects expired access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in with an immediately-expiring access token
|
And I am logged in with an immediately-expiring access token
|
||||||
When I submit a request to a protected endpoint with the expired token
|
When I submit a request to a protected endpoint with the expired token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Unauthorized"
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects token signed with wrong secret
|
Scenario: Protected endpoint rejects token signed with wrong secret
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an access token signed with the wrong secret
|
And I have an access token signed with the wrong secret
|
||||||
When I submit a request to a protected endpoint with the tampered token
|
When I submit a request to a protected endpoint with the tampered token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Unauthorized"
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects refresh token as access token
|
Scenario: Protected endpoint rejects refresh token as access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in
|
And I am logged in
|
||||||
When I submit a request to a protected endpoint with my refresh token instead of access token
|
When I submit a request to a protected endpoint with my refresh token instead of access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
Scenario: Protected endpoint rejects confirmation token as access token
|
Scenario: Protected endpoint rejects confirmation token as access token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token
|
And I have a valid confirmation token
|
||||||
When I submit a request to a protected endpoint with my confirmation token instead of access token
|
When I submit a request to a protected endpoint with my confirmation token instead of access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
@@ -1,76 +1,77 @@
|
|||||||
Feature: User Account Confirmation
|
Feature: User Account Confirmation
|
||||||
As a newly registered user
|
As a newly registered user
|
||||||
I want to confirm my email address via a validation token
|
I want to confirm my email address via a validation token
|
||||||
So that my account is fully activated
|
So that my account is fully activated
|
||||||
Scenario: Successful confirmation with valid token
|
|
||||||
Given the API is running
|
|
||||||
And I have registered a new account
|
|
||||||
And I have a valid confirmation token for my account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
When I submit a confirmation request with the valid token
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And the response JSON should have "message" containing "is confirmed"
|
|
||||||
|
|
||||||
Scenario: Re-confirming an already verified account remains successful
|
Scenario: Successful confirmation with valid token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token for my account
|
And I have a valid confirmation token for my account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a confirmation request with the valid token
|
When I submit a confirmation request with the valid token
|
||||||
And I submit the same confirmation request again
|
Then the response has HTTP status 200
|
||||||
Then the response has HTTP status 200
|
And the response JSON should have "message" containing "is confirmed"
|
||||||
And the response JSON should have "message" containing "is confirmed"
|
|
||||||
|
|
||||||
Scenario: Confirmation fails with invalid token
|
Scenario: Re-confirming an already verified account remains successful
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a valid confirmation token for my account
|
||||||
When I submit a confirmation request with an invalid token
|
And I have a valid access token for my account
|
||||||
Then the response has HTTP status 401
|
When I submit a confirmation request with the valid token
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
And I submit the same confirmation request again
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "is confirmed"
|
||||||
|
|
||||||
Scenario: Confirmation fails with expired token
|
Scenario: Confirmation fails with invalid token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have an expired confirmation token for my account
|
And I have a valid access token for my account
|
||||||
And I have a valid access token for my account
|
When I submit a confirmation request with an invalid token
|
||||||
When I submit a confirmation request with the expired token
|
Then the response has HTTP status 401
|
||||||
Then the response has HTTP status 401
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
|
||||||
|
|
||||||
Scenario: Confirmation fails with tampered token (wrong secret)
|
Scenario: Confirmation fails with expired token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a confirmation token signed with the wrong secret
|
And I have an expired confirmation token for my account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a confirmation request with the tampered token
|
When I submit a confirmation request with the expired token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
Scenario: Confirmation fails when token is missing
|
Scenario: Confirmation fails with tampered token (wrong secret)
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a confirmation token signed with the wrong secret
|
||||||
When I submit a confirmation request with a missing token
|
And I have a valid access token for my account
|
||||||
Then the response has HTTP status 400
|
When I submit a confirmation request with the tampered token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
Scenario: Confirmation endpoint only accepts POST requests
|
Scenario: Confirmation fails when token is missing
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have a valid confirmation token
|
And I have registered a new account
|
||||||
When I submit a confirmation request using an invalid HTTP method
|
And I have a valid access token for my account
|
||||||
Then the response has HTTP status 404
|
When I submit a confirmation request with a missing token
|
||||||
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Confirmation fails with malformed token
|
Scenario: Confirmation endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have a valid confirmation token
|
||||||
And I have a valid access token for my account
|
When I submit a confirmation request using an invalid HTTP method
|
||||||
When I submit a confirmation request with a malformed token
|
Then the response has HTTP status 404
|
||||||
Then the response has HTTP status 401
|
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
|
||||||
|
|
||||||
Scenario: Confirmation fails without an access token
|
Scenario: Confirmation fails with malformed token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a confirmation request with the valid token without an access token
|
When I submit a confirmation request with a malformed token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
|
Scenario: Confirmation fails without an access token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token for my account
|
||||||
|
When I submit a confirmation request with the valid token without an access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
@@ -3,37 +3,37 @@ As a registered user
|
|||||||
I want to log in to my account
|
I want to log in to my account
|
||||||
So that I receive an authentication token to access authenticated routes
|
So that I receive an authentication token to access authenticated routes
|
||||||
|
|
||||||
Scenario: Successful login with valid credentials
|
Scenario: Successful login with valid credentials
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
When I submit a login request with a username and password
|
When I submit a login request with a username and password
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" equal "Logged in successfully."
|
And the response JSON should have "message" equal "Logged in successfully."
|
||||||
And the response JSON should have an access token
|
And the response JSON should have an access token
|
||||||
|
|
||||||
Scenario: Login fails with invalid credentials
|
Scenario: Login fails with invalid credentials
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I do not have an existing account
|
And I do not have an existing account
|
||||||
When I submit a login request with a username and password
|
When I submit a login request with a username and password
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" equal "Invalid username or password."
|
And the response JSON should have "message" equal "Invalid username or password."
|
||||||
|
|
||||||
Scenario: Login fails when required missing username
|
Scenario: Login fails when required missing username
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request with a missing username
|
When I submit a login request with a missing username
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Login fails when required missing password
|
Scenario: Login fails when required missing password
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request with a missing password
|
When I submit a login request with a missing password
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Login fails when both username and password are missing
|
Scenario: Login fails when both username and password are missing
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request with both username and password missing
|
When I submit a login request with both username and password missing
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Login endpoint only accepts POST requests
|
Scenario: Login endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a login request using a GET request
|
When I submit a login request using a GET request
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
@@ -3,8 +3,8 @@ As a client of the API
|
|||||||
I want consistent 404 responses
|
I want consistent 404 responses
|
||||||
So that consumers can gracefully handle missing routes
|
So that consumers can gracefully handle missing routes
|
||||||
|
|
||||||
Scenario: GET request to an invalid route returns 404
|
Scenario: GET request to an invalid route returns 404
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I send an HTTP request "GET" to "/invalid-route"
|
When I send an HTTP request "GET" to "/invalid-route"
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
And the response JSON should have "message" equal "Route not found."
|
And the response JSON should have "message" equal "Route not found."
|
||||||
@@ -1,60 +1,60 @@
|
|||||||
Feature: User Registration
|
Feature: User Registration
|
||||||
As a new user
|
As a new user
|
||||||
I want to register an account
|
I want to register an account
|
||||||
So that I can log in and access authenticated routes
|
So that I can log in and access authenticated routes
|
||||||
|
|
||||||
Scenario: Successful registration with valid details
|
Scenario: Successful registration with valid details
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
|
| newuser | New | User | newuser@example.com | 1990-01-01 | Password1! |
|
||||||
Then the response has HTTP status 201
|
Then the response has HTTP status 201
|
||||||
And the response JSON should have "message" equal "User registered successfully."
|
And the response JSON should have "message" equal "User registered successfully."
|
||||||
And the response JSON should have an access token
|
And the response JSON should have an access token
|
||||||
|
|
||||||
Scenario: Registration fails with existing username
|
Scenario: Registration fails with existing username
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
| test.user | Test | User | example@example.com | 2001-11-11 | Password1! |
|
||||||
Then the response has HTTP status 409
|
Then the response has HTTP status 409
|
||||||
|
|
||||||
Scenario: Registration fails with existing email
|
Scenario: Registration fails with existing email
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
| newuser | New | User | test.user@thebiergarten.app | 1990-01-01 | Password1! |
|
||||||
Then the response has HTTP status 409
|
Then the response has HTTP status 409
|
||||||
|
|
||||||
Scenario: Registration fails with missing required fields
|
Scenario: Registration fails with missing required fields
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| | New | User | | | Password1! |
|
| | New | User | | | Password1! |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Registration fails with invalid email format
|
Scenario: Registration fails with invalid email format
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
|
| newuser | New | User | invalidemail | 1990-01-01 | Password1! |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Registration fails with weak password
|
Scenario: Registration fails with weak password
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
|
| newuser | New | User | newuser@example.com | 1990-01-01 | weakpass |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
|
Scenario: Cannot register a user younger than 19 years of age (regulatory requirement)
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request with values:
|
When I submit a registration request with values:
|
||||||
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
| Username | FirstName | LastName | Email | DateOfBirth | Password |
|
||||||
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
|
| younguser | Young | User | younguser@example.com | {underage_date} | Password1! |
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Registration endpoint only accepts POST requests
|
Scenario: Registration endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a registration request using a GET request
|
When I submit a registration request using a GET request
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
@@ -1,36 +1,36 @@
|
|||||||
Feature: Resend Confirmation Email
|
Feature: Resend Confirmation Email
|
||||||
As a user who did not receive the confirmation email
|
As a user who did not receive the confirmation email
|
||||||
I want to request a resend of the confirmation email
|
I want to request a resend of the confirmation email
|
||||||
So that I can obtain a working confirmation link while preventing abuse
|
So that I can obtain a working confirmation link while preventing abuse
|
||||||
|
|
||||||
Scenario: Legitimate resend for an unconfirmed user
|
Scenario: Legitimate resend for an unconfirmed user
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a resend confirmation request for my account
|
When I submit a resend confirmation request for my account
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
Scenario: Resend is a no-op for an already confirmed user
|
Scenario: Resend is a no-op for an already confirmed user
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid confirmation token for my account
|
And I have a valid confirmation token for my account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
And I have confirmed my account
|
And I have confirmed my account
|
||||||
When I submit a resend confirmation request for my account
|
When I submit a resend confirmation request for my account
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
Scenario: Resend is a no-op for a non-existent user
|
Scenario: Resend is a no-op for a non-existent user
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I submit a resend confirmation request for a non-existent user
|
When I submit a resend confirmation request for a non-existent user
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" containing "confirmation email has been resent"
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
Scenario: Resend requires authentication
|
Scenario: Resend requires authentication
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have registered a new account
|
And I have registered a new account
|
||||||
When I submit a resend confirmation request without an access token
|
When I submit a resend confirmation request without an access token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
@@ -1,39 +1,39 @@
|
|||||||
Feature: Token Refresh
|
Feature: Token Refresh
|
||||||
As an authenticated user
|
As an authenticated user
|
||||||
I want to refresh my access token using my refresh token
|
I want to refresh my access token using my refresh token
|
||||||
So that I can maintain my session without logging in again
|
So that I can maintain my session without logging in again
|
||||||
|
|
||||||
Scenario: Successful token refresh with valid refresh token
|
Scenario: Successful token refresh with valid refresh token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in
|
And I am logged in
|
||||||
When I submit a refresh token request with a valid refresh token
|
When I submit a refresh token request with a valid refresh token
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "message" equal "Token refreshed successfully."
|
And the response JSON should have "message" equal "Token refreshed successfully."
|
||||||
And the response JSON should have a new access token
|
And the response JSON should have a new access token
|
||||||
And the response JSON should have a new refresh token
|
And the response JSON should have a new refresh token
|
||||||
|
|
||||||
Scenario: Token refresh fails with invalid refresh token
|
Scenario: Token refresh fails with invalid refresh token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a refresh token request with an invalid refresh token
|
When I submit a refresh token request with an invalid refresh token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Invalid"
|
And the response JSON should have "message" containing "Invalid"
|
||||||
|
|
||||||
Scenario: Token refresh fails with expired refresh token
|
Scenario: Token refresh fails with expired refresh token
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have an existing account
|
And I have an existing account
|
||||||
And I am logged in with an immediately-expiring refresh token
|
And I am logged in with an immediately-expiring refresh token
|
||||||
When I submit a refresh token request with the expired refresh token
|
When I submit a refresh token request with the expired refresh token
|
||||||
Then the response has HTTP status 401
|
Then the response has HTTP status 401
|
||||||
And the response JSON should have "message" containing "Invalid token"
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
Scenario: Token refresh fails when refresh token is missing
|
Scenario: Token refresh fails when refresh token is missing
|
||||||
Given the API is running
|
Given the API is running
|
||||||
When I submit a refresh token request with a missing refresh token
|
When I submit a refresh token request with a missing refresh token
|
||||||
Then the response has HTTP status 400
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
Scenario: Token refresh endpoint only accepts POST requests
|
Scenario: Token refresh endpoint only accepts POST requests
|
||||||
Given the API is running
|
Given the API is running
|
||||||
And I have a valid refresh token
|
And I have a valid refresh token
|
||||||
When I submit a refresh token request using a GET request
|
When I submit a refresh token request using a GET request
|
||||||
Then the response has HTTP status 404
|
Then the response has HTTP status 404
|
||||||
@@ -1,23 +1,20 @@
|
|||||||
using Domain.Entities;
|
using Features.Emails.Services;
|
||||||
using Service.Emails;
|
|
||||||
|
|
||||||
namespace API.Specs.Mocks;
|
namespace API.Specs.Mocks;
|
||||||
|
|
||||||
public class MockEmailService : IEmailService
|
public class MockEmailDispatcher : IEmailDispatcher
|
||||||
{
|
{
|
||||||
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
||||||
|
|
||||||
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
||||||
|
|
||||||
public Task SendRegistrationEmailAsync(
|
public Task SendRegistrationEmailAsync(string firstName, string email, string confirmationToken)
|
||||||
UserAccount createdUser,
|
|
||||||
string confirmationToken
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
SentRegistrationEmails.Add(
|
SentRegistrationEmails.Add(
|
||||||
new RegistrationEmail
|
new RegistrationEmail
|
||||||
{
|
{
|
||||||
UserAccount = createdUser,
|
FirstName = firstName,
|
||||||
|
Email = email,
|
||||||
ConfirmationToken = confirmationToken,
|
ConfirmationToken = confirmationToken,
|
||||||
SentAt = DateTime.UtcNow,
|
SentAt = DateTime.UtcNow,
|
||||||
}
|
}
|
||||||
@@ -27,14 +24,16 @@ public class MockEmailService : IEmailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task SendResendConfirmationEmailAsync(
|
public Task SendResendConfirmationEmailAsync(
|
||||||
UserAccount user,
|
string firstName,
|
||||||
|
string email,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
SentResendConfirmationEmails.Add(
|
SentResendConfirmationEmails.Add(
|
||||||
new ResendConfirmationEmail
|
new ResendConfirmationEmail
|
||||||
{
|
{
|
||||||
UserAccount = user,
|
FirstName = firstName,
|
||||||
|
Email = email,
|
||||||
ConfirmationToken = confirmationToken,
|
ConfirmationToken = confirmationToken,
|
||||||
SentAt = DateTime.UtcNow,
|
SentAt = DateTime.UtcNow,
|
||||||
}
|
}
|
||||||
@@ -51,14 +50,16 @@ public class MockEmailService : IEmailService
|
|||||||
|
|
||||||
public class RegistrationEmail
|
public class RegistrationEmail
|
||||||
{
|
{
|
||||||
public UserAccount UserAccount { get; init; } = null!;
|
public string FirstName { get; init; } = string.Empty;
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
public string ConfirmationToken { get; init; } = string.Empty;
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
public DateTime SentAt { get; init; }
|
public DateTime SentAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ResendConfirmationEmail
|
public class ResendConfirmationEmail
|
||||||
{
|
{
|
||||||
public UserAccount UserAccount { get; init; } = null!;
|
public string FirstName { get; init; } = string.Empty;
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
public string ConfirmationToken { get; init; } = string.Empty;
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
public DateTime SentAt { get; init; }
|
public DateTime SentAt { get; init; }
|
||||||
}
|
}
|
||||||
@@ -3,19 +3,14 @@ using Infrastructure.Email;
|
|||||||
namespace API.Specs.Mocks;
|
namespace API.Specs.Mocks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mock email provider for testing that doesn't actually send emails.
|
/// Mock email provider for testing that doesn't actually send emails.
|
||||||
/// Tracks sent emails for verification in tests if needed.
|
/// Tracks sent emails for verification in tests if needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MockEmailProvider : IEmailProvider
|
public class MockEmailProvider : IEmailProvider
|
||||||
{
|
{
|
||||||
public List<SentEmail> SentEmails { get; } = new();
|
public List<SentEmail> SentEmails { get; } = new();
|
||||||
|
|
||||||
public Task SendAsync(
|
public Task SendAsync(string to, string subject, string body, bool isHtml = true)
|
||||||
string to,
|
|
||||||
string subject,
|
|
||||||
string body,
|
|
||||||
bool isHtml = true
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
SentEmails.Add(
|
SentEmails.Add(
|
||||||
new SentEmail
|
new SentEmail
|
||||||
@@ -31,12 +26,7 @@ public class MockEmailProvider : IEmailProvider
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendAsync(
|
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
|
||||||
IEnumerable<string> to,
|
|
||||||
string subject,
|
|
||||||
string body,
|
|
||||||
bool isHtml = true
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
SentEmails.Add(
|
SentEmails.Add(
|
||||||
new SentEmail
|
new SentEmail
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using API.Specs;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Reqnroll;
|
using Reqnroll;
|
||||||
|
|
||||||
@@ -15,14 +15,12 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
|
|
||||||
private HttpClient GetClient()
|
private HttpClient GetClient()
|
||||||
{
|
{
|
||||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out var client))
|
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
|
||||||
{
|
|
||||||
return client;
|
return client;
|
||||||
}
|
|
||||||
|
|
||||||
var factory = scenario.TryGetValue<TestApiFactory>(
|
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
|
||||||
FactoryKey,
|
FactoryKey,
|
||||||
out var f
|
out TestApiFactory? f
|
||||||
)
|
)
|
||||||
? f
|
? f
|
||||||
: new TestApiFactory();
|
: new TestApiFactory();
|
||||||
@@ -46,37 +44,27 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
string jsonBody
|
string jsonBody
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var client = GetClient();
|
HttpClient client = GetClient();
|
||||||
|
|
||||||
var requestMessage = new HttpRequestMessage(new HttpMethod(method), url)
|
HttpRequestMessage requestMessage = new(new HttpMethod(method), url)
|
||||||
{
|
{
|
||||||
Content = new StringContent(
|
Content = new StringContent(jsonBody, Encoding.UTF8, "application/json"),
|
||||||
jsonBody,
|
|
||||||
System.Text.Encoding.UTF8,
|
|
||||||
"application/json"
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
var responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I send an HTTP request {string} to {string}")]
|
[When("I send an HTTP request {string} to {string}")]
|
||||||
public async Task WhenISendAnHttpRequestStringToString(
|
public async Task WhenISendAnHttpRequestStringToString(string method, string url)
|
||||||
string method,
|
|
||||||
string url
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var client = GetClient();
|
HttpClient client = GetClient();
|
||||||
var requestMessage = new HttpRequestMessage(
|
HttpRequestMessage requestMessage = new(new HttpMethod(method), url);
|
||||||
new HttpMethod(method),
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
url
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
);
|
|
||||||
var response = await client.SendAsync(requestMessage);
|
|
||||||
var responseBody = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
scenario[ResponseKey] = response;
|
scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
@@ -86,7 +74,7 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
|
public void ThenTheResponseStatusCodeShouldBeInt(int expected)
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
((int)response!.StatusCode).Should().Be(expected);
|
((int)response!.StatusCode).Should().Be(expected);
|
||||||
@@ -96,57 +84,42 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
|
public void ThenTheResponseHasHttpStatusInt(int expectedCode)
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue("No response was received from the API");
|
.BeTrue("No response was received from the API");
|
||||||
((int)response!.StatusCode).Should().Be(expectedCode);
|
((int)response!.StatusCode).Should().Be(expectedCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Then("the response JSON should have {string} equal {string}")]
|
[Then("the response JSON should have {string} equal {string}")]
|
||||||
public void ThenTheResponseJsonShouldHaveStringEqualString(
|
public void ThenTheResponseJsonShouldHaveStringEqualString(string field, string expected)
|
||||||
string field,
|
|
||||||
string expected
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
|
||||||
.BeTrue();
|
|
||||||
scenario
|
|
||||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
|
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(responseBody!);
|
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
var root = doc.RootElement;
|
JsonElement root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty(field, out var value))
|
if (!root.TryGetProperty(field, out JsonElement value))
|
||||||
{
|
{
|
||||||
root.TryGetProperty("payload", out var payloadElem)
|
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue(
|
.BeTrue(
|
||||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
payloadElem
|
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
|
||||||
.ValueKind.Should()
|
|
||||||
.Be(JsonValueKind.Object, "payload must be an object");
|
|
||||||
payloadElem
|
payloadElem
|
||||||
.TryGetProperty(field, out value)
|
.TryGetProperty(field, out value)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue(
|
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
|
||||||
"Expected field '{0}' to be present inside 'payload'",
|
|
||||||
field
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
value
|
value
|
||||||
.ValueKind.Should()
|
.ValueKind.Should()
|
||||||
.Be(
|
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
|
||||||
JsonValueKind.String,
|
|
||||||
"Expected field '{0}' to be a string",
|
|
||||||
field
|
|
||||||
);
|
|
||||||
value.GetString().Should().Be(expected);
|
value.GetString().Should().Be(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,45 +130,33 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
scenario
|
scenario
|
||||||
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
|
||||||
.Should()
|
|
||||||
.BeTrue();
|
|
||||||
scenario
|
|
||||||
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue();
|
.BeTrue();
|
||||||
|
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(responseBody!);
|
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||||
var root = doc.RootElement;
|
JsonElement root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty(field, out var value))
|
if (!root.TryGetProperty(field, out JsonElement value))
|
||||||
{
|
{
|
||||||
root.TryGetProperty("payload", out var payloadElem)
|
root.TryGetProperty("payload", out JsonElement payloadElem)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue(
|
.BeTrue(
|
||||||
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||||
field
|
field
|
||||||
);
|
);
|
||||||
payloadElem
|
payloadElem.ValueKind.Should().Be(JsonValueKind.Object, "payload must be an object");
|
||||||
.ValueKind.Should()
|
|
||||||
.Be(JsonValueKind.Object, "payload must be an object");
|
|
||||||
payloadElem
|
payloadElem
|
||||||
.TryGetProperty(field, out value)
|
.TryGetProperty(field, out value)
|
||||||
.Should()
|
.Should()
|
||||||
.BeTrue(
|
.BeTrue("Expected field '{0}' to be present inside 'payload'", field);
|
||||||
"Expected field '{0}' to be present inside 'payload'",
|
|
||||||
field
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
value
|
value
|
||||||
.ValueKind.Should()
|
.ValueKind.Should()
|
||||||
.Be(
|
.Be(JsonValueKind.String, "Expected field '{0}' to be a string", field);
|
||||||
JsonValueKind.String,
|
string? actualValue = value.GetString();
|
||||||
"Expected field '{0}' to be a string",
|
|
||||||
field
|
|
||||||
);
|
|
||||||
var actualValue = value.GetString();
|
|
||||||
actualValue
|
actualValue
|
||||||
.Should()
|
.Should()
|
||||||
.Contain(
|
.Contain(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,39 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using API.Specs.Mocks;
|
using API.Specs.Mocks;
|
||||||
|
using Features.Emails.Services;
|
||||||
using Infrastructure.Email;
|
using Infrastructure.Email;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Service.Emails;
|
|
||||||
|
|
||||||
namespace API.Specs
|
namespace API.Specs;
|
||||||
|
|
||||||
|
public class TestApiFactory : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
public class TestApiFactory : WebApplicationFactory<Program>
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
{
|
{
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
builder.UseEnvironment("Testing");
|
||||||
|
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
builder.UseEnvironment("Testing");
|
// Replace the real email provider with mock for testing
|
||||||
|
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
|
||||||
|
d.ServiceType == typeof(IEmailProvider)
|
||||||
|
);
|
||||||
|
|
||||||
builder.ConfigureServices(services =>
|
if (emailProviderDescriptor != null)
|
||||||
{
|
services.Remove(emailProviderDescriptor);
|
||||||
// Replace the real email provider with mock for testing
|
|
||||||
var emailProviderDescriptor = services.SingleOrDefault(d =>
|
|
||||||
d.ServiceType == typeof(IEmailProvider)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (emailProviderDescriptor != null)
|
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
||||||
{
|
|
||||||
services.Remove(emailProviderDescriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
// Replace the real email dispatcher with mock for testing
|
||||||
|
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
||||||
|
d.ServiceType == typeof(IEmailDispatcher)
|
||||||
|
);
|
||||||
|
|
||||||
// Replace the real email service with mock for testing
|
if (emailDispatcherDescriptor != null)
|
||||||
var emailServiceDescriptor = services.SingleOrDefault(d =>
|
services.Remove(emailDispatcherDescriptor);
|
||||||
d.ServiceType == typeof(IEmailService)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (emailServiceDescriptor != null)
|
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
||||||
{
|
});
|
||||||
services.Remove(emailServiceDescriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddScoped<IEmailService, MockEmailService>();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,37 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/API/">
|
<Folder Name="/API/">
|
||||||
<Project Path="API/API.Core/API.Core.csproj" />
|
<Project Path="API/API.Core/API.Core.csproj"/>
|
||||||
<Project Path="API/API.Specs/API.Specs.csproj" />
|
<Project Path="API/API.Specs/API.Specs.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Database/">
|
<Folder Name="/Database/">
|
||||||
<Project Path="Database/Database.Migrations/Database.Migrations.csproj" />
|
<Project Path="Database/Database.Migrations/Database.Migrations.csproj"/>
|
||||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
<Project Path="Database/Database.Seed/Database.Seed.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Domain/">
|
<Folder Name="/Domain/">
|
||||||
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj"/>
|
||||||
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj"/>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Infrastructure/">
|
<Folder Name="/Infrastructure/">
|
||||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj"/>
|
||||||
<Project
|
<Project
|
||||||
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj"/>
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Database.Migrations</RootNamespace>
|
<RootNamespace>Database.Migrations</RootNamespace>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="scripts/**/*.sql" />
|
<EmbeddedResource Include="scripts/**/*.sql" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="..\..\.dockerignore">
|
<Content Include="..\..\.dockerignore">
|
||||||
<Link>.dockerignore</Link>
|
<Link>.dockerignore</Link>
|
||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,95 +1,136 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DbUp;
|
using DbUp;
|
||||||
|
using DbUp.Engine;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
namespace Database.Migrations;
|
namespace Database.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point for the database migration runner. Reads connection details from
|
||||||
|
/// environment variables, optionally clears and recreates the target database, and
|
||||||
|
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
||||||
|
/// </summary>
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
|
/// <summary>The connection string for the target application database (<c>DB_NAME</c>).</summary>
|
||||||
|
private static readonly string connectionString = BuildConnectionString();
|
||||||
|
|
||||||
|
/// <summary>The connection string for the <c>master</c> database, used for create/drop operations.</summary>
|
||||||
|
private static readonly string masterConnectionString = BuildConnectionString("master");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||||
|
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||||
|
/// environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="databaseName">
|
||||||
|
/// The database (initial catalog) to connect to. When <c>null</c>, falls back to the
|
||||||
|
/// <c>DB_NAME</c> environment variable.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>A fully built SQL Server connection string.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <c>DB_SERVER</c>, <c>DB_USER</c>, <c>DB_PASSWORD</c>, or (when
|
||||||
|
/// <paramref name="databaseName" /> is <c>null</c>) <c>DB_NAME</c> is not set.
|
||||||
|
/// </exception>
|
||||||
private static string BuildConnectionString(string? databaseName = null)
|
private static string BuildConnectionString(string? databaseName = null)
|
||||||
{
|
{
|
||||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
string server =
|
||||||
|
Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||||
|
|
||||||
var dbName = databaseName
|
string dbName =
|
||||||
|
databaseName
|
||||||
?? Environment.GetEnvironmentVariable("DB_NAME")
|
?? Environment.GetEnvironmentVariable("DB_NAME")
|
||||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||||
|
|
||||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
string user =
|
||||||
|
Environment.GetEnvironmentVariable("DB_USER")
|
||||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||||
|
|
||||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
string password =
|
||||||
|
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||||
|
|
||||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
string trustServerCertificate =
|
||||||
?? "True";
|
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
||||||
|
|
||||||
var builder = new SqlConnectionStringBuilder
|
SqlConnectionStringBuilder builder = new()
|
||||||
{
|
{
|
||||||
DataSource = server,
|
DataSource = server,
|
||||||
InitialCatalog = dbName,
|
InitialCatalog = dbName,
|
||||||
UserID = user,
|
UserID = user,
|
||||||
Password = password,
|
Password = password,
|
||||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||||
Encrypt = true
|
Encrypt = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly string connectionString = BuildConnectionString();
|
/// <summary>
|
||||||
private static readonly string masterConnectionString = BuildConnectionString("master");
|
/// Applies all pending SQL migration scripts embedded in this assembly to the target
|
||||||
|
/// database using DbUp, logging progress to the console.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> if the upgrade completed successfully; otherwise <c>false</c>.</returns>
|
||||||
private static bool DeployMigrations()
|
private static bool DeployMigrations()
|
||||||
{
|
{
|
||||||
var upgrader = DeployChanges
|
UpgradeEngine? upgrader = DeployChanges
|
||||||
.To.SqlDatabase(connectionString)
|
.To.SqlDatabase(connectionString)
|
||||||
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
.WithScriptsEmbeddedInAssembly(Assembly.GetExecutingAssembly())
|
||||||
.LogToConsole()
|
.LogToConsole()
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var result = upgrader.PerformUpgrade();
|
DatabaseUpgradeResult? result = upgrader.PerformUpgrade();
|
||||||
return result.Successful;
|
return result.Successful;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the <c>Biergarten</c> database if it exists, first forcing it into single-user
|
||||||
|
/// mode with rollback to terminate any existing connections.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// <c>true</c> if the database was dropped (or did not exist) without error; <c>false</c>
|
||||||
|
/// if an error occurred while connecting or dropping the database.
|
||||||
|
/// </returns>
|
||||||
private static bool ClearDatabase()
|
private static bool ClearDatabase()
|
||||||
{
|
{
|
||||||
var myConn = new SqlConnection(masterConnectionString);
|
SqlConnection myConn = new(masterConnectionString);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
myConn.Open();
|
myConn.Open();
|
||||||
|
|
||||||
// First, set the database to single user mode to close all connections
|
// First, set the database to single user mode to close all connections
|
||||||
var setModeCommand = new SqlCommand(
|
SqlCommand setModeCommand = new(
|
||||||
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') " +
|
"IF EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten') "
|
||||||
"ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
+ "ALTER DATABASE [Biergarten] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;",
|
||||||
myConn);
|
myConn
|
||||||
|
);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
setModeCommand.ExecuteNonQuery();
|
setModeCommand.ExecuteNonQuery();
|
||||||
Console.WriteLine("Database set to single user mode.");
|
Console.WriteLine("Database set to single user mode.");
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
Console.WriteLine($"Warning: Could not set single user mode: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then drop the database
|
// Then drop the database
|
||||||
var dropCommand = new SqlCommand("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
SqlCommand dropCommand = new("DROP DATABASE IF EXISTS [Biergarten];", myConn);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
dropCommand.ExecuteNonQuery();
|
dropCommand.ExecuteNonQuery();
|
||||||
Console.WriteLine("Database cleared successfully.");
|
Console.WriteLine("Database cleared successfully.");
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error dropping database: {ex}");
|
Console.WriteLine($"Error dropping database: {ex}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error clearing database: {ex}");
|
Console.WriteLine($"Error clearing database: {ex}");
|
||||||
return false;
|
return false;
|
||||||
@@ -97,50 +138,61 @@ public static class Program
|
|||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (myConn.State == ConnectionState.Open)
|
if (myConn.State == ConnectionState.Open)
|
||||||
{
|
|
||||||
myConn.Close();
|
myConn.Close();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the <c>Biergarten</c> database on the master connection if it does not
|
||||||
|
/// already exist. Errors encountered while creating the database are logged but do
|
||||||
|
/// not stop execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> always; this method does not propagate database errors as a failure result.</returns>
|
||||||
private static bool CreateDatabaseIfNotExists()
|
private static bool CreateDatabaseIfNotExists()
|
||||||
{
|
{
|
||||||
var myConn = new SqlConnection(masterConnectionString);
|
SqlConnection myConn = new(masterConnectionString);
|
||||||
|
|
||||||
const string str = """
|
const string str = """
|
||||||
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
|
IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Biergarten')
|
||||||
CREATE DATABASE [Biergarten]
|
CREATE DATABASE [Biergarten]
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var myCommand = new SqlCommand(str, myConn);
|
SqlCommand myCommand = new(str, myConn);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
myConn.Open();
|
myConn.Open();
|
||||||
myCommand.ExecuteNonQuery();
|
myCommand.ExecuteNonQuery();
|
||||||
Console.WriteLine("Database creation command executed successfully.");
|
Console.WriteLine("Database creation command executed successfully.");
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Error creating database: {ex}");
|
Console.WriteLine($"Error creating database: {ex}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (myConn.State == ConnectionState.Open)
|
if (myConn.State == ConnectionState.Open)
|
||||||
{
|
|
||||||
myConn.Close();
|
myConn.Close();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migration runner entry point. Optionally clears the existing database when the
|
||||||
|
/// <c>CLEAR_DATABASE</c> environment variable is set to <c>"true"</c>, ensures the
|
||||||
|
/// database exists, then deploys all pending migrations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="args">Command-line arguments (unused).</param>
|
||||||
|
/// <returns><c>0</c> if migrations completed successfully; <c>1</c> if they failed or an error occurred.</returns>
|
||||||
public static int Main(string[] args)
|
public static int Main(string[] args)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Starting database migrations...");
|
Console.WriteLine("Starting database migrations...");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
string? clearDatabase = Environment.GetEnvironmentVariable("CLEAR_DATABASE");
|
||||||
if (clearDatabase == "true")
|
if (clearDatabase == "true")
|
||||||
{
|
{
|
||||||
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
|
Console.WriteLine("CLEAR_DATABASE is enabled. Clearing existing database...");
|
||||||
@@ -148,18 +200,16 @@ public static class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
CreateDatabaseIfNotExists();
|
CreateDatabaseIfNotExists();
|
||||||
var success = DeployMigrations();
|
bool success = DeployMigrations();
|
||||||
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
Console.WriteLine("Database migrations completed successfully.");
|
Console.WriteLine("Database migrations completed successfully.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
Console.WriteLine("Database migrations failed.");
|
||||||
Console.WriteLine("Database migrations failed.");
|
return 1;
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,22 +24,22 @@ CREATE TABLE dbo.UserAccount
|
|||||||
UserAccountID UNIQUEIDENTIFIER
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||||
|
|
||||||
Username VARCHAR(64) NOT NULL,
|
Username VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
FirstName NVARCHAR(128) NOT NULL,
|
FirstName NVARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
LastName NVARCHAR(128) NOT NULL,
|
LastName NVARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
Email VARCHAR(128) NOT NULL,
|
Email VARCHAR(128) NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserAccount_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
DateOfBirth DATE NOT NULL,
|
DateOfBirth DATE NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserAccount
|
CONSTRAINT PK_UserAccount
|
||||||
PRIMARY KEY (UserAccountID),
|
PRIMARY KEY (UserAccountID),
|
||||||
@@ -56,29 +56,30 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
CREATE TABLE Photo -- All photos must be linked to a user account, you cannot delete a user account if they have uploaded photos
|
||||||
(
|
(
|
||||||
PhotoID UNIQUEIDENTIFIER
|
PhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_PhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
Hyperlink NVARCHAR(256),
|
Hyperlink NVARCHAR(256),
|
||||||
-- storage is handled via filesystem or cloud service
|
-- storage is handled via filesystem or cloud service
|
||||||
|
|
||||||
UploadedByID UNIQUEIDENTIFIER NOT NULL,
|
UploadedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
UploadedAt DATETIME NOT NULL
|
UploadedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_Photo_UploadedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_Photo
|
CONSTRAINT PK_Photo
|
||||||
PRIMARY KEY (PhotoID),
|
PRIMARY KEY (PhotoID),
|
||||||
|
|
||||||
CONSTRAINT FK_Photo_UploadedBy
|
CONSTRAINT FK_Photo_UploadedBy
|
||||||
FOREIGN KEY (UploadedByID)
|
FOREIGN KEY (UploadedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_Photo_UploadedByID
|
||||||
ON Photo(UploadedByID);
|
ON Photo(UploadedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -86,31 +87,32 @@ CREATE NONCLUSTERED INDEX IX_Photo_UploadedByID
|
|||||||
|
|
||||||
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
||||||
(
|
(
|
||||||
UserAvatarID UNIQUEIDENTIFIER
|
UserAvatarID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserAvatar_UserAccount
|
CONSTRAINT FK_UserAvatar_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_UserAvatar_PhotoID
|
CONSTRAINT FK_UserAvatar_PhotoID
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo(PhotoID),
|
REFERENCES Photo (PhotoID),
|
||||||
|
|
||||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||||
UNIQUE (UserAccountID)
|
UNIQUE (UserAccountID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||||
ON UserAvatar(UserAccountID);
|
ON UserAvatar(UserAccountID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -118,29 +120,30 @@ CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
|||||||
|
|
||||||
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
CREATE TABLE UserVerification -- delete verification data when user account is deleted
|
||||||
(
|
(
|
||||||
UserVerificationID UNIQUEIDENTIFIER
|
UserVerificationID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
|
CONSTRAINT DF_UserVerificationID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
VerificationDateTime DATETIME NOT NULL
|
VerificationDateTime DATETIME NOT NULL
|
||||||
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserVerification
|
CONSTRAINT PK_UserVerification
|
||||||
PRIMARY KEY (UserVerificationID),
|
PRIMARY KEY (UserVerificationID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserVerification_UserAccount
|
CONSTRAINT FK_UserVerification_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT AK_UserVerification_UserAccountID
|
CONSTRAINT AK_UserVerification_UserAccountID
|
||||||
UNIQUE (UserAccountID)
|
UNIQUE (UserAccountID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserVerification_UserAccount
|
||||||
ON UserVerification(UserAccountID);
|
ON UserVerification(UserAccountID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -151,37 +154,39 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
UserCredentialID UNIQUEIDENTIFIER
|
UserCredentialID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
|
CONSTRAINT DF_UserCredentialID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Expiry DATETIME NOT NULL
|
Expiry DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||||
|
|
||||||
Hash NVARCHAR(256) NOT NULL,
|
Hash NVARCHAR(256) NOT NULL,
|
||||||
-- uses argon2
|
-- uses argon2
|
||||||
|
|
||||||
IsRevoked BIT NOT NULL
|
IsRevoked BIT NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
|
CONSTRAINT DF_UserCredential_IsRevoked DEFAULT 0,
|
||||||
|
|
||||||
RevokedAt DATETIME NULL,
|
RevokedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserCredential
|
CONSTRAINT PK_UserCredential
|
||||||
PRIMARY KEY (UserCredentialID),
|
PRIMARY KEY (UserCredentialID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserCredential_UserAccount
|
CONSTRAINT FK_UserCredential_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||||
ON UserCredential(UserAccountID);
|
ON UserCredential(UserAccountID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||||
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||||
INCLUDE (Hash);
|
INCLUDE (Hash);
|
||||||
|
|
||||||
@@ -190,39 +195,42 @@ CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
|||||||
|
|
||||||
CREATE TABLE UserFollow
|
CREATE TABLE UserFollow
|
||||||
(
|
(
|
||||||
UserFollowID UNIQUEIDENTIFIER
|
UserFollowID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
CONSTRAINT DF_UserFollowID DEFAULT NEWID(),
|
||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_UserFollow
|
CONSTRAINT PK_UserFollow
|
||||||
PRIMARY KEY (UserFollowID),
|
PRIMARY KEY (UserFollowID),
|
||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccount
|
CONSTRAINT FK_UserFollow_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||||
FOREIGN KEY (FollowingID)
|
FOREIGN KEY (FollowingID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CK_CannotFollowOwnAccount
|
CONSTRAINT CK_CannotFollowOwnAccount
|
||||||
CHECK (UserAccountID != FollowingID)
|
CHECK (UserAccountID != FollowingID
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
||||||
ON UserFollow(UserAccountID, FollowingID);
|
ON UserFollow(UserAccountID, FollowingID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||||
ON UserFollow(FollowingID, UserAccountID);
|
ON UserFollow(FollowingID, UserAccountID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -230,14 +238,14 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
|||||||
|
|
||||||
CREATE TABLE Country
|
CREATE TABLE Country
|
||||||
(
|
(
|
||||||
CountryID UNIQUEIDENTIFIER
|
CountryID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
CONSTRAINT DF_CountryID DEFAULT NEWID(),
|
||||||
|
|
||||||
CountryName NVARCHAR(100) NOT NULL,
|
CountryName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
ISO3166_1 CHAR(2) NOT NULL,
|
ISO3166_1 CHAR(2) NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_Country
|
CONSTRAINT PK_Country
|
||||||
PRIMARY KEY (CountryID),
|
PRIMARY KEY (CountryID),
|
||||||
@@ -251,17 +259,17 @@ CREATE TABLE Country
|
|||||||
|
|
||||||
CREATE TABLE StateProvince
|
CREATE TABLE StateProvince
|
||||||
(
|
(
|
||||||
StateProvinceID UNIQUEIDENTIFIER
|
StateProvinceID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
|
CONSTRAINT DF_StateProvinceID DEFAULT NEWID(),
|
||||||
|
|
||||||
StateProvinceName NVARCHAR(100) NOT NULL,
|
StateProvinceName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
ISO3166_2 CHAR(6) NOT NULL,
|
ISO3166_2 CHAR(6) NOT NULL,
|
||||||
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
-- eg 'US-CA' for California, 'CA-ON' for Ontario
|
||||||
|
|
||||||
CountryID UNIQUEIDENTIFIER NOT NULL,
|
CountryID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_StateProvince
|
CONSTRAINT PK_StateProvince
|
||||||
PRIMARY KEY (StateProvinceID),
|
PRIMARY KEY (StateProvinceID),
|
||||||
@@ -271,10 +279,11 @@ CREATE TABLE StateProvince
|
|||||||
|
|
||||||
CONSTRAINT FK_StateProvince_Country
|
CONSTRAINT FK_StateProvince_Country
|
||||||
FOREIGN KEY (CountryID)
|
FOREIGN KEY (CountryID)
|
||||||
REFERENCES Country(CountryID)
|
REFERENCES Country (CountryID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_StateProvince_Country
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_StateProvince_Country
|
||||||
ON StateProvince(CountryID);
|
ON StateProvince(CountryID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -282,24 +291,25 @@ CREATE NONCLUSTERED INDEX IX_StateProvince_Country
|
|||||||
|
|
||||||
CREATE TABLE City
|
CREATE TABLE City
|
||||||
(
|
(
|
||||||
CityID UNIQUEIDENTIFIER
|
CityID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
CONSTRAINT DF_CityID DEFAULT NEWID(),
|
||||||
|
|
||||||
CityName NVARCHAR(100) NOT NULL,
|
CityName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
|
StateProvinceID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_City
|
CONSTRAINT PK_City
|
||||||
PRIMARY KEY (CityID),
|
PRIMARY KEY (CityID),
|
||||||
|
|
||||||
CONSTRAINT FK_City_StateProvince
|
CONSTRAINT FK_City_StateProvince
|
||||||
FOREIGN KEY (StateProvinceID)
|
FOREIGN KEY (StateProvinceID)
|
||||||
REFERENCES StateProvince(StateProvinceID)
|
REFERENCES StateProvince (StateProvinceID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_City_StateProvince
|
||||||
ON City(StateProvinceID);
|
ON City(StateProvinceID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -310,29 +320,30 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
BreweryPostID UNIQUEIDENTIFIER
|
BreweryPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryName NVARCHAR(256) NOT NULL,
|
BreweryName NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(512) NOT NULL,
|
Description NVARCHAR(512) NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BreweryPost_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME NULL,
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPost
|
CONSTRAINT PK_BreweryPost
|
||||||
PRIMARY KEY (BreweryPostID),
|
PRIMARY KEY (BreweryPostID),
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPost_UserAccount
|
CONSTRAINT FK_BreweryPost_UserAccount
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION
|
ON DELETE NO ACTION
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||||
ON BreweryPost(PostedByID);
|
ON BreweryPost(PostedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -343,19 +354,19 @@ CREATE TABLE BreweryPostLocation
|
|||||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostLocationID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
AddressLine1 NVARCHAR(256) NOT NULL,
|
AddressLine1 NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
AddressLine2 NVARCHAR(256),
|
AddressLine2 NVARCHAR(256),
|
||||||
|
|
||||||
PostalCode NVARCHAR(20) NOT NULL,
|
PostalCode NVARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Coordinates GEOGRAPHY NULL,
|
Coordinates GEOGRAPHY NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPostLocation
|
CONSTRAINT PK_BreweryPostLocation
|
||||||
PRIMARY KEY (BreweryPostLocationID),
|
PRIMARY KEY (BreweryPostLocationID),
|
||||||
@@ -365,18 +376,20 @@ CREATE TABLE BreweryPostLocation
|
|||||||
|
|
||||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost(BreweryPostID)
|
REFERENCES BreweryPost (BreweryPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostLocation_City
|
CONSTRAINT FK_BreweryPostLocation_City
|
||||||
FOREIGN KEY (CityID)
|
FOREIGN KEY (CityID)
|
||||||
REFERENCES City(CityID)
|
REFERENCES City (CityID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||||
ON BreweryPostLocation(BreweryPostID);
|
ON BreweryPostLocation(BreweryPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||||
ON BreweryPostLocation(CityID);
|
ON BreweryPostLocation(CityID);
|
||||||
|
|
||||||
-- To assess when the time comes:
|
-- To assess when the time comes:
|
||||||
@@ -399,33 +412,35 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
|||||||
BreweryPostPhotoID UNIQUEIDENTIFIER
|
BreweryPostPhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostPhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
BreweryPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
LinkedAt DATETIME NOT NULL
|
LinkedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BreweryPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BreweryPostPhoto
|
CONSTRAINT PK_BreweryPostPhoto
|
||||||
PRIMARY KEY (BreweryPostPhotoID),
|
PRIMARY KEY (BreweryPostPhotoID),
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
|
CONSTRAINT FK_BreweryPostPhoto_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost(BreweryPostID)
|
REFERENCES BreweryPost (BreweryPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BreweryPostPhoto_Photo
|
CONSTRAINT FK_BreweryPostPhoto_Photo
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo(PhotoID)
|
REFERENCES Photo (PhotoID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -436,11 +451,11 @@ CREATE TABLE BeerStyle
|
|||||||
BeerStyleID UNIQUEIDENTIFIER
|
BeerStyleID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerStyleID DEFAULT NEWID(),
|
||||||
|
|
||||||
StyleName NVARCHAR(100) NOT NULL,
|
StyleName NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(MAX),
|
Description NVARCHAR(MAX),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerStyle
|
CONSTRAINT PK_BeerStyle
|
||||||
PRIMARY KEY (BeerStyleID),
|
PRIMARY KEY (BeerStyleID),
|
||||||
@@ -454,47 +469,47 @@ CREATE TABLE BeerStyle
|
|||||||
|
|
||||||
CREATE TABLE BeerPost
|
CREATE TABLE BeerPost
|
||||||
(
|
(
|
||||||
BeerPostID UNIQUEIDENTIFIER
|
BeerPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
Name NVARCHAR(100) NOT NULL,
|
Name NVARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(MAX) NOT NULL,
|
Description NVARCHAR(MAX) NOT NULL,
|
||||||
|
|
||||||
ABV DECIMAL(4,2) NOT NULL,
|
ABV DECIMAL(4, 2) NOT NULL,
|
||||||
-- Alcohol By Volume (typically 0-67%)
|
-- Alcohol By Volume (typically 0-67%)
|
||||||
|
|
||||||
IBU INT NOT NULL,
|
IBU INT NOT NULL,
|
||||||
-- International Bitterness Units (typically 0-120)
|
-- International Bitterness Units (typically 0-120)
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
|
BeerStyleID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
BrewedByID UNIQUEIDENTIFIER NOT NULL,
|
BrewedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPost_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPost
|
CONSTRAINT PK_BeerPost
|
||||||
PRIMARY KEY (BeerPostID),
|
PRIMARY KEY (BeerPostID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_PostedBy
|
CONSTRAINT FK_BeerPost_PostedBy
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_BeerStyle
|
CONSTRAINT FK_BeerPost_BeerStyle
|
||||||
FOREIGN KEY (BeerStyleID)
|
FOREIGN KEY (BeerStyleID)
|
||||||
REFERENCES BeerStyle(BeerStyleID),
|
REFERENCES BeerStyle (BeerStyleID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_Brewery
|
CONSTRAINT FK_BeerPost_Brewery
|
||||||
FOREIGN KEY (BrewedByID)
|
FOREIGN KEY (BrewedByID)
|
||||||
REFERENCES BreweryPost(BreweryPostID),
|
REFERENCES BreweryPost (BreweryPostID),
|
||||||
|
|
||||||
CONSTRAINT CHK_BeerPost_ABV
|
CONSTRAINT CHK_BeerPost_ABV
|
||||||
CHECK (ABV >= 0 AND ABV <= 67),
|
CHECK (ABV >= 0 AND ABV <= 67),
|
||||||
@@ -503,13 +518,16 @@ CREATE TABLE BeerPost
|
|||||||
CHECK (IBU >= 0 AND IBU <= 120)
|
CHECK (IBU >= 0 AND IBU <= 120)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPost_PostedBy
|
||||||
ON BeerPost(PostedByID);
|
ON BeerPost(PostedByID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPost_BeerStyle
|
||||||
ON BeerPost(BeerStyleID);
|
ON BeerPost(BeerStyleID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPost_BrewedBy
|
||||||
ON BeerPost(BrewedByID);
|
ON BeerPost(BrewedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -520,33 +538,35 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
|||||||
BeerPostPhotoID UNIQUEIDENTIFIER
|
BeerPostPhotoID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostPhotoID DEFAULT NEWID(),
|
||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
PhotoID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
LinkedAt DATETIME NOT NULL
|
LinkedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPostPhoto_LinkedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostPhoto
|
CONSTRAINT PK_BeerPostPhoto
|
||||||
PRIMARY KEY (BeerPostPhotoID),
|
PRIMARY KEY (BeerPostPhotoID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostPhoto_BeerPost
|
CONSTRAINT FK_BeerPostPhoto_BeerPost
|
||||||
FOREIGN KEY (BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
REFERENCES BeerPost(BeerPostID)
|
REFERENCES BeerPost (BeerPostID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostPhoto_Photo
|
CONSTRAINT FK_BeerPostPhoto_Photo
|
||||||
FOREIGN KEY (PhotoID)
|
FOREIGN KEY (PhotoID)
|
||||||
REFERENCES Photo(PhotoID)
|
REFERENCES Photo (PhotoID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -557,39 +577,41 @@ CREATE TABLE BeerPostComment
|
|||||||
BeerPostCommentID UNIQUEIDENTIFIER
|
BeerPostCommentID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
CONSTRAINT DF_BeerPostComment DEFAULT NEWID(),
|
||||||
|
|
||||||
Comment NVARCHAR(250) NOT NULL,
|
Comment NVARCHAR(250) NOT NULL,
|
||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Rating INT NOT NULL,
|
Rating INT NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME NOT NULL
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
UpdatedAt DATETIME NULL,
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostComment
|
CONSTRAINT PK_BeerPostComment
|
||||||
PRIMARY KEY (BeerPostCommentID),
|
PRIMARY KEY (BeerPostCommentID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||||
FOREIGN KEY (BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
REFERENCES BeerPost(BeerPostID),
|
REFERENCES BeerPost (BeerPostID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_UserAccount
|
CONSTRAINT FK_BeerPostComment_UserAccount
|
||||||
FOREIGN KEY (CommentedByID)
|
FOREIGN KEY (CommentedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount (UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CHK_BeerPostComment_Rating
|
CONSTRAINT CHK_BeerPostComment_Rating
|
||||||
CHECK (Rating BETWEEN 1 AND 5)
|
CHECK (Rating BETWEEN 1 AND 5)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||||
ON BeerPostComment(BeerPostID);
|
ON BeerPostComment(BeerPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
CREATE
|
||||||
|
NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||||
ON BeerPostComment(CommentedByID);
|
ON BeerPostComment(CommentedByID);
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
CREATE OR ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
CREATE
|
||||||
(
|
OR
|
||||||
|
ALTER FUNCTION dbo.UDF_GetCountryIdByCode
|
||||||
|
(
|
||||||
@CountryCode NVARCHAR(2)
|
@CountryCode NVARCHAR(2)
|
||||||
)
|
)
|
||||||
RETURNS UNIQUEIDENTIFIER
|
RETURNS UNIQUEIDENTIFIER
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @CountryId UNIQUEIDENTIFIER;
|
DECLARE
|
||||||
|
@CountryId UNIQUEIDENTIFIER;
|
||||||
|
|
||||||
SELECT @CountryId = CountryID
|
SELECT @CountryId = CountryID
|
||||||
FROM dbo.Country
|
FROM dbo.Country
|
||||||
WHERE ISO3166_1 = @CountryCode;
|
WHERE ISO3166_1 = @CountryCode;
|
||||||
|
|
||||||
RETURN @CountryId;
|
RETURN @CountryId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
CREATE OR ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
CREATE
|
||||||
(
|
OR
|
||||||
|
ALTER FUNCTION dbo.UDF_GetStateProvinceIdByCode
|
||||||
|
(
|
||||||
@StateProvinceCode NVARCHAR(6)
|
@StateProvinceCode NVARCHAR(6)
|
||||||
)
|
)
|
||||||
RETURNS UNIQUEIDENTIFIER
|
RETURNS UNIQUEIDENTIFIER
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER;
|
DECLARE
|
||||||
SELECT @StateProvinceId = StateProvinceID
|
@StateProvinceId UNIQUEIDENTIFIER;
|
||||||
FROM dbo.StateProvince
|
SELECT @StateProvinceId = StateProvinceID
|
||||||
WHERE ISO3166_2 = @StateProvinceCode;
|
FROM dbo.StateProvince
|
||||||
RETURN @StateProvinceId;
|
WHERE ISO3166_2 = @StateProvinceCode;
|
||||||
|
RETURN @StateProvinceId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,36 +1,34 @@
|
|||||||
|
CREATE
|
||||||
CREATE OR ALTER PROCEDURE usp_CreateUserAccount
|
OR
|
||||||
(
|
ALTER PROCEDURE usp_CreateUserAccount
|
||||||
|
(
|
||||||
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
@UserAccountId UNIQUEIDENTIFIER OUTPUT,
|
||||||
@Username VARCHAR(64),
|
@Username VARCHAR (64),
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR(128)
|
@Email VARCHAR (128)
|
||||||
)
|
|
||||||
AS
|
|
||||||
BEGIN
|
|
||||||
SET NOCOUNT ON;
|
|
||||||
|
|
||||||
DECLARE @Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
|
||||||
|
|
||||||
INSERT INTO UserAccount
|
|
||||||
(
|
|
||||||
Username,
|
|
||||||
FirstName,
|
|
||||||
LastName,
|
|
||||||
DateOfBirth,
|
|
||||||
Email
|
|
||||||
)
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
|
DECLARE
|
||||||
|
@Inserted TABLE (UserAccountID UNIQUEIDENTIFIER);
|
||||||
|
|
||||||
|
INSERT INTO UserAccount
|
||||||
|
(Username,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
DateOfBirth,
|
||||||
|
Email)
|
||||||
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
OUTPUT INSERTED.UserAccountID INTO @Inserted
|
||||||
VALUES
|
VALUES
|
||||||
(
|
(
|
||||||
@Username,
|
@Username, @FirstName, @LastName, @DateOfBirth, @Email
|
||||||
@FirstName,
|
|
||||||
@LastName,
|
|
||||||
@DateOfBirth,
|
|
||||||
@Email
|
|
||||||
);
|
);
|
||||||
|
|
||||||
SELECT @UserAccountId = UserAccountID FROM @Inserted;
|
SELECT @UserAccountId = UserAccountID
|
||||||
|
FROM @Inserted;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
|
CREATE
|
||||||
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
|
OR
|
||||||
(
|
ALTER PROCEDURE usp_DeleteUserAccount
|
||||||
|
(
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON
|
SET
|
||||||
|
NOCOUNT ON
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
IF NOT EXISTS (SELECT 1 FROM UserAccount WHERE UserAccountId = @UserAccountId)
|
||||||
BEGIN
|
BEGIN
|
||||||
RAISERROR('UserAccount with the specified ID does not exist.', 16,
|
RAISERROR
|
||||||
|
('UserAccount with the specified ID does not exist.', 16,
|
||||||
1);
|
1);
|
||||||
ROLLBACK TRANSACTION
|
ROLLBACK TRANSACTION
|
||||||
RETURN
|
RETURN
|
||||||
END
|
END
|
||||||
|
|
||||||
DELETE FROM UserAccount
|
DELETE
|
||||||
WHERE UserAccountId = @UserAccountId;
|
FROM UserAccount
|
||||||
|
WHERE UserAccountId = @UserAccountId;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
|
CREATE
|
||||||
AS
|
OR
|
||||||
|
ALTER PROCEDURE usp_GetAllUserAccounts
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount;
|
FROM dbo.UserAccount;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail(
|
CREATE
|
||||||
@Email VARCHAR(128)
|
OR
|
||||||
)
|
ALTER PROCEDURE usp_GetUserAccountByEmail(
|
||||||
AS
|
@Email VARCHAR (128)
|
||||||
|
)
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE Email = @Email;
|
WHERE Email = @Email;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
CREATE OR ALTER PROCEDURE USP_GetUserAccountById(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE USP_GetUserAccountById(
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE UserAccountID = @UserAccountId;
|
WHERE UserAccountID = @UserAccountId;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername(
|
CREATE
|
||||||
@Username VARCHAR(64)
|
OR
|
||||||
)
|
ALTER PROCEDURE usp_GetUserAccountByUsername(
|
||||||
AS
|
@Username VARCHAR (64)
|
||||||
|
)
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT UserAccountID,
|
SELECT UserAccountID,
|
||||||
Username,
|
Username,
|
||||||
FirstName,
|
FirstName,
|
||||||
LastName,
|
LastName,
|
||||||
Email,
|
Email,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
UpdatedAt,
|
UpdatedAt,
|
||||||
DateOfBirth,
|
DateOfBirth,
|
||||||
Timer
|
Timer
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE Username = @Username;
|
WHERE Username = @Username;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
CREATE OR ALTER PROCEDURE usp_UpdateUserAccount(
|
CREATE
|
||||||
@Username VARCHAR(64),
|
OR
|
||||||
|
ALTER PROCEDURE usp_UpdateUserAccount(
|
||||||
|
@Username VARCHAR (64),
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR(128),
|
@Email VARCHAR (128),
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET
|
SET
|
||||||
NOCOUNT ON;
|
NOCOUNT ON;
|
||||||
|
|
||||||
UPDATE UserAccount
|
UPDATE UserAccount
|
||||||
SET Username = @Username,
|
SET Username = @Username,
|
||||||
FirstName = @FirstName,
|
FirstName = @FirstName,
|
||||||
LastName = @LastName,
|
LastName = @LastName,
|
||||||
DateOfBirth = @DateOfBirth,
|
DateOfBirth = @DateOfBirth,
|
||||||
Email = @Email
|
Email = @Email
|
||||||
WHERE UserAccountId = @UserAccountId;
|
WHERE UserAccountId = @UserAccountId;
|
||||||
|
|
||||||
IF @@ROWCOUNT = 0
|
IF
|
||||||
BEGIN
|
@@ROWCOUNT = 0
|
||||||
|
BEGIN
|
||||||
THROW
|
THROW
|
||||||
50001, 'UserAccount with the specified ID does not exist.', 1;
|
50001, 'UserAccount with the specified ID does not exist.', 1;
|
||||||
END
|
END
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_GetActiveUserCredentialByUserAccountId(
|
||||||
@UserAccountId UNIQUEIDENTIFIER
|
@UserAccountId UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
SELECT
|
SELECT UserCredentialId,
|
||||||
UserCredentialId,
|
UserAccountId,
|
||||||
UserAccountId,
|
Hash,
|
||||||
Hash,
|
IsRevoked,
|
||||||
IsRevoked,
|
CreatedAt,
|
||||||
CreatedAt,
|
RevokedAt
|
||||||
RevokedAt
|
FROM dbo.UserCredential
|
||||||
FROM dbo.UserCredential
|
WHERE UserAccountId = @UserAccountId
|
||||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0;
|
AND IsRevoked = 0;
|
||||||
END;
|
END;
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_InvalidateUserCredential(
|
||||||
@UserAccountId_ UNIQUEIDENTIFIER
|
@UserAccountId_ UNIQUEIDENTIFIER
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
EXEC dbo.USP_GetUserAccountByID @UserAccountId = @UserAccountId_;
|
||||||
IF @@ROWCOUNT = 0
|
IF
|
||||||
|
@@ROWCOUNT = 0
|
||||||
THROW 50001, 'User account not found', 1;
|
THROW 50001, 'User account not found', 1;
|
||||||
|
|
||||||
-- invalidate all other credentials by setting them to revoked
|
-- invalidate all other credentials by setting them to revoked
|
||||||
UPDATE dbo.UserCredential
|
UPDATE dbo.UserCredential
|
||||||
SET IsRevoked = 1,
|
SET IsRevoked = 1,
|
||||||
RevokedAt = GETDATE()
|
RevokedAt = GETDATE()
|
||||||
WHERE UserAccountId = @UserAccountId_
|
WHERE UserAccountId = @UserAccountId_
|
||||||
AND IsRevoked != 1;
|
AND IsRevoked != 1;
|
||||||
|
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END;
|
END;
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_RegisterUser(
|
CREATE
|
||||||
@Username VARCHAR(64),
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_RegisterUser(
|
||||||
|
@Username VARCHAR (64),
|
||||||
@FirstName NVARCHAR(128),
|
@FirstName NVARCHAR(128),
|
||||||
@LastName NVARCHAR(128),
|
@LastName NVARCHAR(128),
|
||||||
@DateOfBirth DATETIME,
|
@DateOfBirth DATETIME,
|
||||||
@Email VARCHAR(128),
|
@Email VARCHAR (128),
|
||||||
@Hash NVARCHAR(MAX)
|
@Hash NVARCHAR(MAX)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
DECLARE @UserAccountId_ UNIQUEIDENTIFIER;
|
DECLARE
|
||||||
|
@UserAccountId_ UNIQUEIDENTIFIER;
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC usp_CreateUserAccount
|
EXEC usp_CreateUserAccount
|
||||||
@UserAccountId = @UserAccountId_ OUTPUT,
|
@UserAccountId = @UserAccountId_ OUTPUT,
|
||||||
@Username = @Username,
|
@Username = @Username,
|
||||||
@FirstName = @FirstName,
|
@FirstName = @FirstName,
|
||||||
@@ -23,20 +29,24 @@ BEGIN
|
|||||||
@DateOfBirth = @DateOfBirth,
|
@DateOfBirth = @DateOfBirth,
|
||||||
@Email = @Email;
|
@Email = @Email;
|
||||||
|
|
||||||
IF @UserAccountId_ IS NULL
|
IF
|
||||||
BEGIN
|
@UserAccountId_ IS NULL
|
||||||
THROW 50000, 'Failed to create user account.', 1;
|
BEGIN
|
||||||
END
|
THROW
|
||||||
|
50000, 'Failed to create user account.', 1;
|
||||||
INSERT INTO dbo.UserCredential
|
END
|
||||||
(UserAccountId, Hash)
|
|
||||||
VALUES (@UserAccountId_, @Hash);
|
INSERT INTO dbo.UserCredential
|
||||||
|
(UserAccountId, Hash)
|
||||||
IF @@ROWCOUNT = 0
|
VALUES (@UserAccountId_, @Hash);
|
||||||
BEGIN
|
|
||||||
THROW 50002, 'Failed to create user credential.', 1;
|
IF
|
||||||
END
|
@@ROWCOUNT = 0
|
||||||
COMMIT TRANSACTION;
|
BEGIN
|
||||||
|
THROW
|
||||||
SELECT @UserAccountId_ AS UserAccountId;
|
50002, 'Failed to create user credential.', 1;
|
||||||
|
END
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
|
SELECT @UserAccountId_ AS UserAccountId;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_RotateUserCredential(
|
||||||
@UserAccountId_ UNIQUEIDENTIFIER,
|
@UserAccountId_ UNIQUEIDENTIFIER,
|
||||||
@Hash NVARCHAR(MAX)
|
@Hash NVARCHAR(MAX)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
BEGIN TRANSACTION;
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountId_
|
||||||
|
|
||||||
IF @@ROWCOUNT = 0
|
IF @@ROWCOUNT = 0
|
||||||
THROW 50001, 'User account not found', 1;
|
THROW 50001, 'User account not found', 1;
|
||||||
|
|
||||||
|
|
||||||
-- invalidate all other credentials -- set them to revoked
|
-- invalidate all other credentials -- set them to revoked
|
||||||
UPDATE dbo.UserCredential
|
UPDATE dbo.UserCredential
|
||||||
SET IsRevoked = 1,
|
SET IsRevoked = 1,
|
||||||
RevokedAt = GETDATE()
|
RevokedAt = GETDATE()
|
||||||
WHERE UserAccountId = @UserAccountId_;
|
WHERE UserAccountId = @UserAccountId_;
|
||||||
|
|
||||||
INSERT INTO dbo.UserCredential
|
INSERT INTO dbo.UserCredential
|
||||||
(UserAccountId, Hash)
|
(UserAccountId, Hash)
|
||||||
VALUES (@UserAccountId_, @Hash);
|
VALUES (@UserAccountId_, @Hash);
|
||||||
|
|
||||||
|
|
||||||
END;
|
END;
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
CREATE
|
||||||
@VerificationDateTime DATETIME = NULL
|
OR
|
||||||
AS
|
ALTER PROCEDURE dbo.USP_CreateUserVerification @UserAccountID_ UNIQUEIDENTIFIER,
|
||||||
|
@VerificationDateTime DATETIME = NULL
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF @VerificationDateTime IS NULL
|
IF
|
||||||
|
@VerificationDateTime IS NULL
|
||||||
SET @VerificationDateTime = GETDATE();
|
SET @VerificationDateTime = GETDATE();
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
EXEC USP_GetUserAccountByID @UserAccountId = @UserAccountID_;
|
||||||
IF @@ROWCOUNT = 0
|
IF
|
||||||
|
@@ROWCOUNT = 0
|
||||||
THROW 50001, 'Could not find a user with that id', 1;
|
THROW 50001, 'Could not find a user with that id', 1;
|
||||||
|
|
||||||
INSERT INTO dbo.UserVerification
|
INSERT INTO dbo.UserVerification
|
||||||
(UserAccountId, VerificationDateTime)
|
(UserAccountId, VerificationDateTime)
|
||||||
VALUES (@UserAccountID_, @VerificationDateTime);
|
VALUES (@UserAccountID_, @VerificationDateTime);
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCity(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateCity(
|
||||||
@CityName NVARCHAR(100),
|
@CityName NVARCHAR(100),
|
||||||
@StateProvinceCode NVARCHAR(6)
|
@StateProvinceCode NVARCHAR(6)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
BEGIN TRANSACTION
|
BEGIN
|
||||||
DECLARE @StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
TRANSACTION
|
||||||
IF @StateProvinceId IS NULL
|
DECLARE
|
||||||
BEGIN
|
@StateProvinceId UNIQUEIDENTIFIER = dbo.UDF_GetStateProvinceIdByCode(@StateProvinceCode);
|
||||||
THROW 50001, 'State/province does not exist', 1;
|
IF
|
||||||
END
|
@StateProvinceId IS NULL
|
||||||
|
BEGIN
|
||||||
|
THROW
|
||||||
|
50001, 'State/province does not exist', 1;
|
||||||
|
END
|
||||||
|
|
||||||
IF EXISTS (SELECT 1
|
IF
|
||||||
|
EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityName = @CityName
|
WHERE CityName = @CityName
|
||||||
AND StateProvinceID = @StateProvinceId)
|
AND StateProvinceID = @StateProvinceId)
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|
||||||
THROW 50002, 'City already exists.', 1;
|
THROW
|
||||||
END
|
50002, 'City already exists.', 1;
|
||||||
|
END
|
||||||
|
|
||||||
INSERT INTO dbo.City
|
INSERT INTO dbo.City
|
||||||
(StateProvinceID, CityName)
|
(StateProvinceID, CityName)
|
||||||
VALUES (@StateProvinceId, @CityName);
|
VALUES (@StateProvinceId, @CityName);
|
||||||
COMMIT TRANSACTION
|
COMMIT TRANSACTION
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateCountry(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateCountry(
|
||||||
@CountryName NVARCHAR(100),
|
@CountryName NVARCHAR(100),
|
||||||
@ISO3166_1 NVARCHAR(2)
|
@ISO3166_1 NVARCHAR(2)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
BEGIN TRANSACTION;
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
IF EXISTS (SELECT 1
|
IF
|
||||||
|
EXISTS (SELECT 1
|
||||||
FROM dbo.Country
|
FROM dbo.Country
|
||||||
WHERE ISO3166_1 = @ISO3166_1)
|
WHERE ISO3166_1 = @ISO3166_1)
|
||||||
THROW 50001, 'Country already exists', 1;
|
THROW 50001, 'Country already exists', 1;
|
||||||
|
|
||||||
INSERT INTO dbo.Country
|
INSERT INTO dbo.Country
|
||||||
(CountryName, ISO3166_1)
|
(CountryName, ISO3166_1)
|
||||||
VALUES
|
VALUES (@CountryName, @ISO3166_1);
|
||||||
(@CountryName, @ISO3166_1);
|
COMMIT TRANSACTION;
|
||||||
COMMIT TRANSACTION;
|
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateStateProvince(
|
||||||
@StateProvinceName NVARCHAR(100),
|
@StateProvinceName NVARCHAR(100),
|
||||||
@ISO3166_2 NVARCHAR(6),
|
@ISO3166_2 NVARCHAR(6),
|
||||||
@CountryCode NVARCHAR(2)
|
@CountryCode NVARCHAR(2)
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF EXISTS (SELECT 1
|
IF
|
||||||
|
EXISTS (SELECT 1
|
||||||
FROM dbo.StateProvince
|
FROM dbo.StateProvince
|
||||||
WHERE ISO3166_2 = @ISO3166_2)
|
WHERE ISO3166_2 = @ISO3166_2)
|
||||||
RETURN;
|
RETURN;
|
||||||
|
|
||||||
DECLARE @CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
DECLARE
|
||||||
IF @CountryId IS NULL
|
@CountryId UNIQUEIDENTIFIER = dbo.UDF_GetCountryIdByCode(@CountryCode);
|
||||||
BEGIN
|
IF
|
||||||
THROW 50001, 'Country does not exist', 1;
|
@CountryId IS NULL
|
||||||
|
BEGIN
|
||||||
|
THROW
|
||||||
|
50001, 'Country does not exist', 1;
|
||||||
|
|
||||||
END
|
END
|
||||||
|
|
||||||
INSERT INTO dbo.StateProvince
|
INSERT INTO dbo.StateProvince
|
||||||
(StateProvinceName, ISO3166_2, CountryID)
|
(StateProvinceName, ISO3166_2, CountryID)
|
||||||
VALUES
|
VALUES (@StateProvinceName, @ISO3166_2, @CountryId);
|
||||||
(@StateProvinceName, @ISO3166_2, @CountryId);
|
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||||
@BreweryName NVARCHAR(256),
|
@BreweryName NVARCHAR(256),
|
||||||
@Description NVARCHAR(512),
|
@Description NVARCHAR(512),
|
||||||
@PostedByID UNIQUEIDENTIFIER,
|
@PostedByID UNIQUEIDENTIFIER,
|
||||||
@@ -7,44 +9,53 @@ CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
|||||||
@AddressLine2 NVARCHAR(256) = NULL,
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
@PostalCode NVARCHAR(20),
|
@PostalCode NVARCHAR(20),
|
||||||
@Coordinates GEOGRAPHY = NULL
|
@Coordinates GEOGRAPHY = NULL
|
||||||
)
|
)
|
||||||
AS
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SET NOCOUNT ON;
|
SET
|
||||||
SET XACT_ABORT ON;
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
IF @BreweryName IS NULL
|
IF
|
||||||
|
@BreweryName IS NULL
|
||||||
THROW 50001, 'Brewery name cannot be null.', 1;
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
IF @Description IS NULL
|
IF
|
||||||
|
@Description IS NULL
|
||||||
THROW 50002, 'Brewery description cannot be null.', 1;
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.UserAccount
|
FROM dbo.UserAccount
|
||||||
WHERE UserAccountID = @PostedByID)
|
WHERE UserAccountID = @PostedByID)
|
||||||
THROW 50404, 'User not found.', 1;
|
THROW 50404, 'User not found.', 1;
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
FROM dbo.City
|
FROM dbo.City
|
||||||
WHERE CityID = @CityID)
|
WHERE CityID = @CityID)
|
||||||
THROW 50404, 'City not found.', 1;
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
DECLARE
|
||||||
DECLARE @NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
@NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||||
|
DECLARE
|
||||||
|
@NewBrewerLocationID UNIQUEIDENTIFIER = NEWID();
|
||||||
|
|
||||||
BEGIN TRANSACTION;
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPost
|
INSERT INTO dbo.BreweryPost
|
||||||
(BreweryPostID, BreweryName, Description, PostedByID)
|
(BreweryPostID, BreweryName, Description, PostedByID)
|
||||||
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
||||||
|
|
||||||
INSERT INTO dbo.BreweryPostLocation
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
VALUES (@NewBrewerLocationID, @NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
||||||
|
|
||||||
COMMIT TRANSACTION;
|
COMMIT TRANSACTION;
|
||||||
|
|
||||||
SELECT @NewBreweryID AS BreweryPostID,
|
SELECT @NewBreweryID AS BreweryPostID,
|
||||||
@NewBrewerLocationID AS BreweryPostLocationID;
|
@NewBrewerLocationID AS BreweryPostLocationID;
|
||||||
|
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_DeleteBrewery
|
||||||
|
@BreweryPostID UNIQUEIDENTIFIER
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
|
FROM dbo.BreweryPost
|
||||||
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
|
THROW 50404, 'Brewery not found.', 1;
|
||||||
|
|
||||||
|
-- BreweryPostLocation and BreweryPostPhoto cascade-delete with their parent BreweryPost.
|
||||||
|
DELETE
|
||||||
|
FROM dbo.BreweryPost
|
||||||
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
|
END
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_GetAllBreweries(
|
||||||
|
@Limit INT = NULL,
|
||||||
|
@Offset INT = NULL
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
|
||||||
|
SELECT bp.BreweryPostID,
|
||||||
|
bp.PostedByID,
|
||||||
|
bp.BreweryName,
|
||||||
|
bp.Description,
|
||||||
|
bp.CreatedAt,
|
||||||
|
bp.UpdatedAt,
|
||||||
|
bp.Timer,
|
||||||
|
bpl.BreweryPostLocationID,
|
||||||
|
bpl.CityID,
|
||||||
|
bpl.AddressLine1,
|
||||||
|
bpl.AddressLine2,
|
||||||
|
bpl.PostalCode,
|
||||||
|
bpl.Coordinates
|
||||||
|
FROM dbo.BreweryPost bp
|
||||||
|
LEFT JOIN dbo.BreweryPostLocation bpl
|
||||||
|
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
|
ORDER BY bp.CreatedAt DESC
|
||||||
|
OFFSET ISNULL(@Offset, 0) ROWS FETCH NEXT ISNULL(@Limit, 2147483647) ROWS ONLY;
|
||||||
|
END
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
CREATE OR ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
CREATE
|
||||||
AS
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_GetBreweryById @BreweryPostID UNIQUEIDENTIFIER
|
||||||
|
AS
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM BreweryPost bp
|
FROM BreweryPost bp
|
||||||
INNER JOIN BreweryPostLocation bpl
|
INNER JOIN BreweryPostLocation bpl
|
||||||
ON bp.BreweryPostID = bpl.BreweryPostID
|
ON bp.BreweryPostID = bpl.BreweryPostID
|
||||||
WHERE bp.BreweryPostID = @BreweryPostID;
|
WHERE bp.BreweryPostID = @BreweryPostID;
|
||||||
END
|
END
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
CREATE
|
||||||
|
OR
|
||||||
|
ALTER PROCEDURE dbo.USP_UpdateBrewery(
|
||||||
|
@BreweryPostID UNIQUEIDENTIFIER,
|
||||||
|
@BreweryName NVARCHAR(256),
|
||||||
|
@Description NVARCHAR(512),
|
||||||
|
@BreweryPostLocationID UNIQUEIDENTIFIER = NULL,
|
||||||
|
@CityID UNIQUEIDENTIFIER = NULL,
|
||||||
|
@AddressLine1 NVARCHAR(256) = NULL,
|
||||||
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
|
@PostalCode NVARCHAR(20) = NULL,
|
||||||
|
@Coordinates GEOGRAPHY = NULL
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET
|
||||||
|
NOCOUNT ON;
|
||||||
|
SET
|
||||||
|
XACT_ABORT ON;
|
||||||
|
|
||||||
|
IF
|
||||||
|
@BreweryName IS NULL
|
||||||
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
|
IF
|
||||||
|
@Description IS NULL
|
||||||
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
|
IF
|
||||||
|
NOT EXISTS (SELECT 1
|
||||||
|
FROM dbo.BreweryPost
|
||||||
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
|
THROW 50404, 'Brewery not found.', 1;
|
||||||
|
|
||||||
|
IF
|
||||||
|
@CityID IS NOT NULL AND NOT EXISTS (SELECT 1
|
||||||
|
FROM dbo.City
|
||||||
|
WHERE CityID = @CityID)
|
||||||
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
|
BEGIN
|
||||||
|
TRANSACTION;
|
||||||
|
|
||||||
|
UPDATE dbo.BreweryPost
|
||||||
|
SET BreweryName = @BreweryName,
|
||||||
|
Description = @Description,
|
||||||
|
UpdatedAt = GETDATE()
|
||||||
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
|
|
||||||
|
IF
|
||||||
|
@CityID IS NULL
|
||||||
|
BEGIN
|
||||||
|
-- No location supplied: clear any existing location for this brewery.
|
||||||
|
DELETE
|
||||||
|
FROM dbo.BreweryPostLocation
|
||||||
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
|
END
|
||||||
|
ELSE IF EXISTS (SELECT 1
|
||||||
|
FROM dbo.BreweryPostLocation
|
||||||
|
WHERE BreweryPostID = @BreweryPostID)
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.BreweryPostLocation
|
||||||
|
SET CityID = @CityID,
|
||||||
|
AddressLine1 = @AddressLine1,
|
||||||
|
AddressLine2 = @AddressLine2,
|
||||||
|
PostalCode = @PostalCode,
|
||||||
|
Coordinates = @Coordinates
|
||||||
|
WHERE BreweryPostID = @BreweryPostID;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
|
(BreweryPostLocationID, BreweryPostID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
|
VALUES (ISNULL(@BreweryPostLocationID, NEWID()), @BreweryPostID, @CityID, @AddressLine1, @AddressLine2,
|
||||||
|
@PostalCode, @Coordinates);
|
||||||
|
END
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Database.Seed</RootNamespace>
|
<RootNamespace>Database.Seed</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
||||||
<PackageReference
|
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||||
Include="Konscious.Security.Cryptography.Argon2"
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||||
Version="1.3.1"
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
/>
|
</ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference
|
</ItemGroup>
|
||||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
|
||||||
</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,22 +3,34 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to
|
||||||
|
/// associate other entities (e.g. breweries, users) with a geographic location.
|
||||||
|
/// Countries must be seeded before states/provinces, which must be seeded before
|
||||||
|
/// cities, since each level references its parent by code. The underlying stored
|
||||||
|
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
|
||||||
|
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
|
||||||
|
/// against an already-seeded database is safe.
|
||||||
|
/// </summary>
|
||||||
internal class LocationSeeder : ISeeder
|
internal class LocationSeeder : ISeeder
|
||||||
{
|
{
|
||||||
private static readonly IReadOnlyList<(
|
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
|
||||||
string CountryName,
|
private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
|
||||||
string CountryCode
|
|
||||||
)> Countries =
|
|
||||||
[
|
[
|
||||||
("Canada", "CA"),
|
("Canada", "CA"),
|
||||||
("Mexico", "MX"),
|
("Mexico", "MX"),
|
||||||
("United States", "US"),
|
("United States", "US"),
|
||||||
];
|
];
|
||||||
|
|
||||||
private static IReadOnlyList<(string StateProvinceName, string StateProvinceCode, string CountryCode)> States
|
/// <summary>
|
||||||
{
|
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
|
||||||
get;
|
/// and the ISO 3166-1 code of its parent country.
|
||||||
} =
|
/// </summary>
|
||||||
|
private static IReadOnlyList<(
|
||||||
|
string StateProvinceName,
|
||||||
|
string StateProvinceCode,
|
||||||
|
string CountryCode
|
||||||
|
)> States { get; } =
|
||||||
[
|
[
|
||||||
("Alabama", "US-AL", "US"),
|
("Alabama", "US-AL", "US"),
|
||||||
("Alaska", "US-AK", "US"),
|
("Alaska", "US-AK", "US"),
|
||||||
@@ -123,6 +135,10 @@ internal class LocationSeeder : ISeeder
|
|||||||
("Ciudad de México", "MX-CMX", "MX"),
|
("Ciudad de México", "MX-CMX", "MX"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
|
||||||
|
/// parent state/province.
|
||||||
|
/// </summary>
|
||||||
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
||||||
[
|
[
|
||||||
("US-CA", "Los Angeles"),
|
("US-CA", "Los Angeles"),
|
||||||
@@ -240,41 +256,41 @@ internal class LocationSeeder : ISeeder
|
|||||||
("MX-ZAC", "Zacatecas"),
|
("MX-ZAC", "Zacatecas"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds all countries, then states/provinces, then cities, in that order, so that
|
||||||
|
/// each level's parent reference already exists by the time it is created.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <returns>A task that completes when all locations have been seeded.</returns>
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
public async Task SeedAsync(SqlConnection connection)
|
||||||
{
|
{
|
||||||
foreach (var (countryName, countryCode) in Countries)
|
foreach ((string countryName, string countryCode) in Countries)
|
||||||
{
|
|
||||||
await CreateCountryAsync(connection, countryName, countryCode);
|
await CreateCountryAsync(connection, countryName, countryCode);
|
||||||
}
|
|
||||||
|
|
||||||
foreach (
|
foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
|
||||||
var (stateProvinceName, stateProvinceCode, countryCode) in States
|
|
||||||
)
|
|
||||||
{
|
|
||||||
await CreateStateProvinceAsync(
|
await CreateStateProvinceAsync(
|
||||||
connection,
|
connection,
|
||||||
stateProvinceName,
|
stateProvinceName,
|
||||||
stateProvinceCode,
|
stateProvinceCode,
|
||||||
countryCode
|
countryCode
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var (stateProvinceCode, cityName) in Cities)
|
foreach ((string stateProvinceCode, string cityName) in Cities)
|
||||||
{
|
|
||||||
await CreateCityAsync(connection, cityName, stateProvinceCode);
|
await CreateCityAsync(connection, cityName, stateProvinceCode);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="countryName">The display name of the country.</param>
|
||||||
|
/// <param name="countryCode">The ISO 3166-1 code of the country.</param>
|
||||||
|
/// <returns>A task that completes when the country has been created.</returns>
|
||||||
private static async Task CreateCountryAsync(
|
private static async Task CreateCountryAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string countryName,
|
string countryName,
|
||||||
string countryCode
|
string countryCode
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
|
||||||
"dbo.USP_CreateCountry",
|
|
||||||
connection
|
|
||||||
);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
command.Parameters.AddWithValue("@CountryName", countryName);
|
command.Parameters.AddWithValue("@CountryName", countryName);
|
||||||
command.Parameters.AddWithValue("@ISO3166_1", countryCode);
|
command.Parameters.AddWithValue("@ISO3166_1", countryCode);
|
||||||
@@ -282,6 +298,12 @@ internal class LocationSeeder : ISeeder
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a single state/province by invoking the <c>dbo.USP_CreateStateProvince</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="stateProvinceName">The display name of the state/province.</param>
|
||||||
|
/// <param name="stateProvinceCode">The ISO 3166-2 code of the state/province.</param>
|
||||||
|
/// <param name="countryCode">The ISO 3166-1 code of the parent country, which must already exist.</param>
|
||||||
|
/// <returns>A task that completes when the state/province has been created.</returns>
|
||||||
private static async Task CreateStateProvinceAsync(
|
private static async Task CreateStateProvinceAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string stateProvinceName,
|
string stateProvinceName,
|
||||||
@@ -289,37 +311,30 @@ internal class LocationSeeder : ISeeder
|
|||||||
string countryCode
|
string countryCode
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
|
||||||
"dbo.USP_CreateStateProvince",
|
|
||||||
connection
|
|
||||||
);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
command.Parameters.AddWithValue(
|
command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
|
||||||
"@StateProvinceName",
|
|
||||||
stateProvinceName
|
|
||||||
);
|
|
||||||
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
|
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
|
||||||
command.Parameters.AddWithValue("@CountryCode", countryCode);
|
command.Parameters.AddWithValue("@CountryCode", countryCode);
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a single city by invoking the <c>dbo.USP_CreateCity</c> stored procedure.</summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="cityName">The display name of the city.</param>
|
||||||
|
/// <param name="stateProvinceCode">The ISO 3166-2 code of the parent state/province, which must already exist.</param>
|
||||||
|
/// <returns>A task that completes when the city has been created.</returns>
|
||||||
private static async Task CreateCityAsync(
|
private static async Task CreateCityAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string cityName,
|
string cityName,
|
||||||
string stateProvinceCode
|
string stateProvinceCode
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new("dbo.USP_CreateCity", connection);
|
||||||
"dbo.USP_CreateCity",
|
|
||||||
connection
|
|
||||||
);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
command.Parameters.AddWithValue("@CityName", cityName);
|
command.Parameters.AddWithValue("@CityName", cityName);
|
||||||
command.Parameters.AddWithValue(
|
command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
|
||||||
"@StateProvinceCode",
|
|
||||||
stateProvinceCode
|
|
||||||
);
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,57 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
using Database.Seed;
|
||||||
using DbUp;
|
using Microsoft.Data.SqlClient;
|
||||||
using System.Reflection;
|
|
||||||
using Database.Seed;
|
|
||||||
|
|
||||||
|
// Entry point for the database seeding utility. Connects to the target database
|
||||||
|
// (retrying on transient failures), then runs each registered ISeeder in order to
|
||||||
|
// populate location and user data.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||||
|
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||||
|
/// environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A fully built SQL Server connection string.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
|
||||||
|
/// is not set.
|
||||||
|
/// </exception>
|
||||||
string BuildConnectionString()
|
string BuildConnectionString()
|
||||||
{
|
{
|
||||||
var server = Environment.GetEnvironmentVariable("DB_SERVER")
|
string server =
|
||||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
|
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||||
|
|
||||||
var dbName = Environment.GetEnvironmentVariable("DB_NAME")
|
string dbName =
|
||||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
Environment.GetEnvironmentVariable("DB_NAME")
|
||||||
|
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||||
|
|
||||||
var user = Environment.GetEnvironmentVariable("DB_USER")
|
string user =
|
||||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
Environment.GetEnvironmentVariable("DB_USER")
|
||||||
|
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||||
|
|
||||||
var password = Environment.GetEnvironmentVariable("DB_PASSWORD")
|
string password =
|
||||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||||
|
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||||
|
|
||||||
var trustServerCertificate = Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE")
|
string trustServerCertificate =
|
||||||
?? "True";
|
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
||||||
|
|
||||||
var builder = new SqlConnectionStringBuilder
|
SqlConnectionStringBuilder builder = new()
|
||||||
{
|
{
|
||||||
DataSource = server,
|
DataSource = server,
|
||||||
InitialCatalog = dbName,
|
InitialCatalog = dbName,
|
||||||
UserID = user,
|
UserID = user,
|
||||||
Password = password,
|
Password = password,
|
||||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||||
Encrypt = true
|
Encrypt = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var connectionString = BuildConnectionString();
|
string connectionString = BuildConnectionString();
|
||||||
|
|
||||||
Console.WriteLine("Attempting to connect to database...");
|
Console.WriteLine("Attempting to connect to database...");
|
||||||
|
|
||||||
@@ -46,7 +61,6 @@ try
|
|||||||
int retryDelayMs = 2000;
|
int retryDelayMs = 2000;
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
connection = new SqlConnection(connectionString);
|
connection = new SqlConnection(connectionString);
|
||||||
@@ -62,24 +76,17 @@ try
|
|||||||
connection?.Dispose();
|
connection?.Dispose();
|
||||||
connection = null;
|
connection = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (connection == null)
|
if (connection == null)
|
||||||
{
|
|
||||||
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
|
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Starting seeding...");
|
Console.WriteLine("Starting seeding...");
|
||||||
|
|
||||||
using (connection)
|
using (connection)
|
||||||
{
|
{
|
||||||
ISeeder[] seeders =
|
ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
|
||||||
[
|
|
||||||
new LocationSeeder(),
|
|
||||||
new UserSeeder(),
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach (var seeder in seeders)
|
foreach (ISeeder seeder in seeders)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
|
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
|
||||||
await seeder.SeedAsync(connection);
|
await seeder.SeedAsync(connection);
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ using Microsoft.Data.SqlClient;
|
|||||||
|
|
||||||
namespace Database.Seed;
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds user accounts, credentials, and verification records. Creates one fixed
|
||||||
|
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
|
||||||
|
/// for testing, followed by a randomly-generated account for each name in
|
||||||
|
/// <see cref="SeedNames" />, each with a random password and date of birth. Skips
|
||||||
|
/// adding a verification record for a user that already has one, so this seeder is
|
||||||
|
/// safe to re-run, though re-running will still attempt to register (and may fail on)
|
||||||
|
/// users that already exist.
|
||||||
|
/// </summary>
|
||||||
internal class UserSeeder : ISeeder
|
internal class UserSeeder : ISeeder
|
||||||
{
|
{
|
||||||
private static readonly IReadOnlyList<(
|
/// <summary>The first/last name pairs used to generate seed user accounts.</summary>
|
||||||
string FirstName,
|
private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
|
||||||
string LastName
|
|
||||||
)> SeedNames =
|
|
||||||
[
|
[
|
||||||
("Aarya", "Mathews"),
|
("Aarya", "Mathews"),
|
||||||
("Aiden", "Wells"),
|
("Aiden", "Wells"),
|
||||||
@@ -116,10 +123,18 @@ internal class UserSeeder : ISeeder
|
|||||||
("Zoie", "Armstrong"),
|
("Zoie", "Armstrong"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a fixed test user account followed by one randomly-generated account
|
||||||
|
/// per entry in <see cref="SeedNames" />, adding a user verification record for each
|
||||||
|
/// newly created account that does not already have one. Progress counts are
|
||||||
|
/// written to the console on completion.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <returns>A task that completes when all users have been seeded.</returns>
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
public async Task SeedAsync(SqlConnection connection)
|
||||||
{
|
{
|
||||||
var generator = new PasswordGenerator();
|
PasswordGenerator generator = new();
|
||||||
var rng = new Random();
|
Random rng = new();
|
||||||
int createdUsers = 0;
|
int createdUsers = 0;
|
||||||
int createdCredentials = 0;
|
int createdCredentials = 0;
|
||||||
int createdVerifications = 0;
|
int createdVerifications = 0;
|
||||||
@@ -129,8 +144,8 @@ internal class UserSeeder : ISeeder
|
|||||||
const string firstName = "Test";
|
const string firstName = "Test";
|
||||||
const string lastName = "User";
|
const string lastName = "User";
|
||||||
const string email = "test.user@thebiergarten.app";
|
const string email = "test.user@thebiergarten.app";
|
||||||
var dob = new DateTime(1985, 03, 01);
|
DateTime dob = new(1985, 03, 01);
|
||||||
var hash = GeneratePasswordHash("password");
|
string hash = GeneratePasswordHash("password");
|
||||||
|
|
||||||
await RegisterUserAsync(
|
await RegisterUserAsync(
|
||||||
connection,
|
connection,
|
||||||
@@ -142,24 +157,19 @@ internal class UserSeeder : ISeeder
|
|||||||
hash
|
hash
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
foreach (var (firstName, lastName) in SeedNames)
|
foreach ((string firstName, string lastName) in SeedNames)
|
||||||
{
|
{
|
||||||
// prepare user fields
|
// prepare user fields
|
||||||
var username = $"{firstName[0]}.{lastName}";
|
string username = $"{firstName[0]}.{lastName}";
|
||||||
var email = $"{firstName}.{lastName}@thebiergarten.app";
|
string email = $"{firstName}.{lastName}@thebiergarten.app";
|
||||||
var dob = GenerateDateOfBirth(rng);
|
DateTime dob = GenerateDateOfBirth(rng);
|
||||||
|
|
||||||
// generate a password and hash it
|
// generate a password and hash it
|
||||||
string pwd = generator.Generate(
|
string pwd = generator.Generate(64, 10, 10);
|
||||||
length: 64,
|
|
||||||
numberOfDigits: 10,
|
|
||||||
numberOfSymbols: 10
|
|
||||||
);
|
|
||||||
string hash = GeneratePasswordHash(pwd);
|
string hash = GeneratePasswordHash(pwd);
|
||||||
|
|
||||||
|
|
||||||
// register the user (creates account + credential)
|
// register the user (creates account + credential)
|
||||||
var id = await RegisterUserAsync(
|
Guid id = await RegisterUserAsync(
|
||||||
connection,
|
connection,
|
||||||
username,
|
username,
|
||||||
firstName,
|
firstName,
|
||||||
@@ -171,9 +181,9 @@ internal class UserSeeder : ISeeder
|
|||||||
createdUsers++;
|
createdUsers++;
|
||||||
createdCredentials++;
|
createdCredentials++;
|
||||||
|
|
||||||
|
|
||||||
// add user verification
|
// add user verification
|
||||||
if (await HasUserVerificationAsync(connection, id)) continue;
|
if (await HasUserVerificationAsync(connection, id))
|
||||||
|
continue;
|
||||||
|
|
||||||
await AddUserVerificationAsync(connection, id);
|
await AddUserVerificationAsync(connection, id);
|
||||||
createdVerifications++;
|
createdVerifications++;
|
||||||
@@ -184,6 +194,18 @@ internal class UserSeeder : ISeeder
|
|||||||
Console.WriteLine($"Added {createdVerifications} user verifications.");
|
Console.WriteLine($"Added {createdVerifications} user verifications.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account and its credential by invoking the
|
||||||
|
/// <c>dbo.USP_RegisterUser</c> stored procedure.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
|
/// <param name="username">The unique username for the account.</param>
|
||||||
|
/// <param name="firstName">The user's first name.</param>
|
||||||
|
/// <param name="lastName">The user's last name.</param>
|
||||||
|
/// <param name="dateOfBirth">The user's date of birth.</param>
|
||||||
|
/// <param name="email">The user's email address.</param>
|
||||||
|
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash" />.</param>
|
||||||
|
/// <returns>The newly created user account's <see cref="Guid" /> identifier.</returns>
|
||||||
private static async Task<Guid> RegisterUserAsync(
|
private static async Task<Guid> RegisterUserAsync(
|
||||||
SqlConnection connection,
|
SqlConnection connection,
|
||||||
string username,
|
string username,
|
||||||
@@ -194,10 +216,9 @@ internal class UserSeeder : ISeeder
|
|||||||
string hash
|
string hash
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand("dbo.USP_RegisterUser", connection);
|
await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
|
|
||||||
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
|
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
|
||||||
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
|
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
|
||||||
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
|
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
|
||||||
@@ -205,17 +226,23 @@ internal class UserSeeder : ISeeder
|
|||||||
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
|
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
|
||||||
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
|
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
|
||||||
|
|
||||||
var result = await command.ExecuteScalarAsync();
|
object? result = await command.ExecuteScalarAsync();
|
||||||
|
|
||||||
|
|
||||||
return (Guid)result!;
|
return (Guid)result!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
|
||||||
|
/// salt, a degree of parallelism matching the available processor count, 64 MB of
|
||||||
|
/// memory, and 4 iterations.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pwd">The plaintext password to hash.</param>
|
||||||
|
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
|
||||||
private static string GeneratePasswordHash(string pwd)
|
private static string GeneratePasswordHash(string pwd)
|
||||||
{
|
{
|
||||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
||||||
|
|
||||||
var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))
|
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
|
||||||
{
|
{
|
||||||
Salt = salt,
|
Salt = salt,
|
||||||
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
||||||
@@ -227,37 +254,45 @@ 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
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM dbo.UserVerification
|
FROM dbo.UserVerification
|
||||||
WHERE UserAccountId = @UserAccountId;
|
WHERE UserAccountId = @UserAccountId;
|
||||||
""";
|
""";
|
||||||
await using var command = new SqlCommand(sql, connection);
|
await using SqlCommand command = new(sql, connection);
|
||||||
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
|
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
|
||||||
var result = await command.ExecuteScalarAsync();
|
object? result = await command.ExecuteScalarAsync();
|
||||||
return result is not null;
|
return result is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task AddUserVerificationAsync(
|
/// <summary>Creates a user verification record by invoking the <c>dbo.USP_CreateUserVerification</c> stored procedure.</summary>
|
||||||
SqlConnection connection,
|
/// <param name="connection">An open connection to the target database.</param>
|
||||||
Guid userAccountId
|
/// <param name="userAccountId">The identifier of the user account to verify.</param>
|
||||||
)
|
/// <returns>A task that completes when the verification record has been created.</returns>
|
||||||
|
private static async Task AddUserVerificationAsync(SqlConnection connection, Guid userAccountId)
|
||||||
{
|
{
|
||||||
await using var command = new SqlCommand(
|
await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
|
||||||
"dbo.USP_CreateUserVerification",
|
|
||||||
connection
|
|
||||||
);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
|
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a random date of birth corresponding to an age between 19 and 48
|
||||||
|
/// years (inclusive), with a random day offset within that birth year.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="random">The random number source to use.</param>
|
||||||
|
/// <returns>A randomly generated date of birth.</returns>
|
||||||
private static DateTime GenerateDateOfBirth(Random random)
|
private static DateTime GenerateDateOfBirth(Random random)
|
||||||
{
|
{
|
||||||
int age = 19 + random.Next(0, 30);
|
int age = 19 + random.Next(0, 30);
|
||||||
|
|||||||
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,8 +1,8 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<RootNamespace>Domain</RootNamespace>
|
<RootNamespace>Domain</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,13 +1,50 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a user-submitted post about a brewery. Maps to the <c>BreweryPost</c> table.
|
||||||
|
/// A user account cannot be deleted while it has an associated brewery post.
|
||||||
|
/// </summary>
|
||||||
public class BreweryPost
|
public class BreweryPost
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key identifying this brewery post. Maps to <c>BreweryPostID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key referencing the <see cref="UserAccount" /> that authored this post. Maps to <c>PostedByID</c>.
|
||||||
|
/// </summary>
|
||||||
public Guid PostedById { get; set; }
|
public Guid PostedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the brewery being posted about.
|
||||||
|
/// </summary>
|
||||||
public string BreweryName { get; set; } = string.Empty;
|
public string BreweryName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Free-text description of the brewery.
|
||||||
|
/// </summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the post was created.
|
||||||
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the post was last updated, or <c>null</c> if it has never been updated.
|
||||||
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public byte[]? Timer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The associated <see cref="BreweryPostLocation" /> for this post, if one has been set. This is a one-to-one
|
||||||
|
/// navigation property.
|
||||||
|
/// </summary>
|
||||||
public BreweryPostLocation? Location { get; set; }
|
public BreweryPostLocation? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,52 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the physical location of a <see cref="BreweryPost" />. Maps to the <c>BreweryPostLocation</c> table.
|
||||||
|
/// Each brewery post has at most one location, and the location is deleted automatically when its parent post is
|
||||||
|
/// deleted.
|
||||||
|
/// </summary>
|
||||||
public class BreweryPostLocation
|
public class BreweryPostLocation
|
||||||
{
|
{
|
||||||
public Guid BreweryPostLocationId { get; set; }
|
/// <summary>
|
||||||
public Guid BreweryPostId { get; set; }
|
/// Primary key identifying this location. Maps to <c>BreweryPostLocationID</c>.
|
||||||
public string AddressLine1 { get; set; } = string.Empty;
|
/// </summary>
|
||||||
public string? AddressLine2 { get; set; }
|
public Guid BreweryPostLocationId { get; set; }
|
||||||
public string PostalCode { get; set; } = string.Empty;
|
|
||||||
public Guid CityId { get; set; }
|
/// <summary>
|
||||||
public byte[]? Coordinates { get; set; }
|
/// Foreign key referencing the owning <see cref="BreweryPost" />. Maps to <c>BreweryPostID</c>; unique, enforcing a
|
||||||
public byte[]? Timer { get; set; }
|
/// one-to-one relationship.
|
||||||
|
/// </summary>
|
||||||
|
public Guid BreweryPostId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The primary street address line.
|
||||||
|
/// </summary>
|
||||||
|
public string AddressLine1 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An optional secondary address line (e.g., suite or unit number).
|
||||||
|
/// </summary>
|
||||||
|
public string? AddressLine2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The postal/ZIP code of the location.
|
||||||
|
/// </summary>
|
||||||
|
public string PostalCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Foreign key referencing the city in which the brewery is located. Maps to <c>CityID</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Guid CityId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialized SQL Server <c>GEOGRAPHY</c> value representing the geographic coordinates of the location, or
|
||||||
|
/// <c>null</c> if not set.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Coordinates { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,55 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a registered user of the application. Maps to the <c>UserAccount</c> table, the root entity
|
||||||
|
/// referenced by <see cref="UserCredential" />, <see cref="UserVerification" />, brewery posts, and other user-owned
|
||||||
|
/// data.
|
||||||
|
/// </summary>
|
||||||
public class UserAccount
|
public class UserAccount
|
||||||
{
|
{
|
||||||
public Guid UserAccountId { get; set; }
|
/// <summary>
|
||||||
public string Username { get; set; } = string.Empty;
|
/// Primary key identifying this user account. Maps to <c>UserAccountID</c>.
|
||||||
public string FirstName { get; set; } = string.Empty;
|
/// </summary>
|
||||||
public string LastName { get; set; } = string.Empty;
|
public Guid UserAccountId { get; set; }
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
public DateTime CreatedAt { get; set; }
|
/// <summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
/// The user's unique username, used for login and display. Enforced unique at the database level.
|
||||||
public DateTime DateOfBirth { get; set; }
|
/// </summary>
|
||||||
public byte[]? Timer { get; set; }
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's first name.
|
||||||
|
/// </summary>
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's last name.
|
||||||
|
/// </summary>
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's email address. Enforced unique at the database level.
|
||||||
|
/// </summary>
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the account was created.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the account was last updated, or <c>null</c> if it has never been updated.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user's date of birth.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime DateOfBirth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an issued authentication credential (refresh token) for a <see cref="UserAccount" />.
|
||||||
|
/// Maps to the <c>UserCredential</c> table. Credentials are deleted automatically when the owning user account is
|
||||||
|
/// deleted.
|
||||||
|
/// </summary>
|
||||||
public class UserCredential
|
public class UserCredential
|
||||||
{
|
{
|
||||||
public Guid UserCredentialId { get; set; }
|
/// <summary>
|
||||||
public Guid UserAccountId { get; set; }
|
/// Primary key identifying this credential. Maps to <c>UserCredentialID</c>.
|
||||||
public DateTime CreatedAt { get; set; }
|
/// </summary>
|
||||||
public DateTime Expiry { get; set; }
|
public Guid UserCredentialId { get; set; }
|
||||||
public string Hash { get; set; } = string.Empty;
|
|
||||||
public byte[]? Timer { get; set; }
|
/// <summary>
|
||||||
|
/// Foreign key referencing the owning <see cref="UserAccount" />. Maps to <c>UserAccountID</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Guid UserAccountId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time the credential was issued.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time after which the credential is no longer valid.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime Expiry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Argon2 hash of the credential's secret value. The plaintext secret is never persisted.
|
||||||
|
/// </summary>
|
||||||
|
public string Hash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a <see cref="UserAccount" /> has completed verification (e.g., email confirmation).
|
||||||
|
/// Maps to the <c>UserVerification</c> table. A user account has at most one verification record,
|
||||||
|
/// and it is deleted automatically when the owning user account is deleted.
|
||||||
|
/// </summary>
|
||||||
public class UserVerification
|
public class UserVerification
|
||||||
{
|
{
|
||||||
public Guid UserVerificationId { get; set; }
|
/// <summary>
|
||||||
public Guid UserAccountId { get; set; }
|
/// Primary key identifying this verification record. Maps to <c>UserVerificationID</c>.
|
||||||
public DateTime VerificationDateTime { get; set; }
|
/// </summary>
|
||||||
public byte[]? Timer { 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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The date and time at which the user account was verified.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime VerificationDateTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL Server <c>ROWVERSION</c> concurrency token used to detect concurrent updates. <c>null</c> until the row has
|
||||||
|
/// been read from the database.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? Timer { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
namespace Domain.Exceptions;
|
namespace Domain.Exceptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
/// Exception thrown when a resource conflict occurs (e.g., duplicate username, email already in use).
|
||||||
/// Maps to HTTP 409 Conflict.
|
/// Maps to HTTP 409 Conflict.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConflictException(string message) : Exception(message);
|
public class ConflictException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a requested resource is not found.
|
/// Exception thrown when a requested resource is not found.
|
||||||
/// Maps to HTTP 404 Not Found.
|
/// Maps to HTTP 404 Not Found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NotFoundException(string message) : Exception(message);
|
public class NotFoundException(string message) : Exception(message);
|
||||||
|
|
||||||
// Domain.Exceptions/UnauthorizedException.cs
|
// Domain.Exceptions/UnauthorizedException.cs
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when authentication fails or is required.
|
/// Exception thrown when authentication fails or is required.
|
||||||
/// Maps to HTTP 401 Unauthorized.
|
/// Maps to HTTP 401 Unauthorized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UnauthorizedException(string message) : Exception(message);
|
public class UnauthorizedException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
/// Exception thrown when a user is authenticated but lacks permission to access a resource.
|
||||||
/// Maps to HTTP 403 Forbidden.
|
/// Maps to HTTP 403 Forbidden.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ForbiddenException(string message) : Exception(message);
|
public class ForbiddenException(string message) : Exception(message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
/// Exception thrown when business rule validation fails (distinct from FluentValidation).
|
||||||
/// Maps to HTTP 400 Bad Request.
|
/// Maps to HTTP 400 Bad Request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ValidationException(string message) : Exception(message);
|
public class ValidationException(string message) : Exception(message);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Commands.ConfirmUser;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Commands;
|
||||||
|
|
||||||
|
public class ConfirmUserHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly ConfirmUserHandler _handler;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
|
||||||
|
public ConfirmUserHandlerTests()
|
||||||
|
{
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
_handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
|
||||||
|
{
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
ClaimsPrincipal principal = new(new ClaimsIdentity(claims));
|
||||||
|
return new ValidatedToken(userId, username, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithValidConfirmationToken_ConfirmsUser()
|
||||||
|
{
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string confirmationToken = "valid-confirmation-token";
|
||||||
|
|
||||||
|
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
||||||
|
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
|
.ReturnsAsync(validatedToken);
|
||||||
|
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
|
||||||
|
|
||||||
|
ConfirmationPayload result = await _handler.Handle(
|
||||||
|
new ConfirmUserCommand(confirmationToken),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserAccountId.Should().Be(userId);
|
||||||
|
result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
const string invalidToken = "invalid-confirmation-token";
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid confirmation token"));
|
||||||
|
|
||||||
|
Func<Task<ConfirmationPayload>> act = async () =>
|
||||||
|
await _handler.Handle(new ConfirmUserCommand(invalidToken), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "nonexistent";
|
||||||
|
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
|
.ReturnsAsync(MakeValidatedToken(userId, username));
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
Func<Task<ConfirmationPayload>> act = async () =>
|
||||||
|
await _handler.Handle(
|
||||||
|
new ConfirmUserCommand(confirmationToken),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
await act.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Commands.RefreshToken;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Commands;
|
||||||
|
|
||||||
|
public class RefreshTokenHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_MapsTokenServiceResult_ToLoginPayload()
|
||||||
|
{
|
||||||
|
Mock<ITokenService> tokenServiceMock = new();
|
||||||
|
RefreshTokenHandler handler = new(tokenServiceMock.Object);
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
|
||||||
|
|
||||||
|
tokenServiceMock
|
||||||
|
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
||||||
|
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
|
||||||
|
|
||||||
|
LoginPayload result = await handler.Handle(
|
||||||
|
new RefreshTokenCommand("old-refresh-token"),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result.UserAccountId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be("testuser");
|
||||||
|
result.RefreshToken.Should().Be("new-refresh-token");
|
||||||
|
result.AccessToken.Should().Be("new-access-token");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Commands.RegisterUser;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.PasswordHashing;
|
||||||
|
using MediatR;
|
||||||
|
using Moq;
|
||||||
|
using Shared.Application.Emails;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Commands;
|
||||||
|
|
||||||
|
public class RegisterUserHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||||
|
private readonly RegisterUserHandler _handler;
|
||||||
|
private readonly Mock<IMediator> _mediatorMock;
|
||||||
|
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
|
||||||
|
public RegisterUserHandlerTests()
|
||||||
|
{
|
||||||
|
_authRepoMock = new Mock<IAuthRepository>();
|
||||||
|
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||||
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
_mediatorMock = new Mock<IMediator>();
|
||||||
|
|
||||||
|
_handler = new RegisterUserHandler(
|
||||||
|
_authRepoMock.Object,
|
||||||
|
_passwordInfraMock.Object,
|
||||||
|
_tokenServiceMock.Object,
|
||||||
|
_mediatorMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegisterUserCommand ValidCommand(
|
||||||
|
string username = "newuser",
|
||||||
|
string email = "john.doe@example.com"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return new RegisterUserCommand(
|
||||||
|
username,
|
||||||
|
"John",
|
||||||
|
"Doe",
|
||||||
|
email,
|
||||||
|
new DateTime(1990, 1, 1),
|
||||||
|
"SecurePassword123!"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
||||||
|
{
|
||||||
|
RegisterUserCommand command = ValidCommand();
|
||||||
|
const string hashedPassword = "hashed_password_value";
|
||||||
|
Guid expectedUserId = Guid.NewGuid();
|
||||||
|
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||||
|
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x =>
|
||||||
|
x.RegisterUserAsync(
|
||||||
|
command.Username,
|
||||||
|
command.FirstName,
|
||||||
|
command.LastName,
|
||||||
|
command.Email,
|
||||||
|
command.DateOfBirth,
|
||||||
|
hashedPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(
|
||||||
|
new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountId = expectedUserId,
|
||||||
|
Username = command.Username,
|
||||||
|
FirstName = command.FirstName,
|
||||||
|
LastName = command.LastName,
|
||||||
|
Email = command.Email,
|
||||||
|
DateOfBirth = command.DateOfBirth,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("access-token");
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("refresh-token");
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("confirmation-token");
|
||||||
|
|
||||||
|
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserAccountId.Should().Be(expectedUserId);
|
||||||
|
result.Username.Should().Be(command.Username);
|
||||||
|
result.AccessToken.Should().Be("access-token");
|
||||||
|
result.RefreshToken.Should().Be("refresh-token");
|
||||||
|
result.ConfirmationEmailSent.Should().BeTrue();
|
||||||
|
|
||||||
|
_mediatorMock.Verify(
|
||||||
|
x => x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
||||||
|
{
|
||||||
|
RegisterUserCommand command = ValidCommand("existinguser");
|
||||||
|
UserAccount existingUser = new()
|
||||||
|
{
|
||||||
|
UserAccountId = Guid.NewGuid(),
|
||||||
|
Username = "existinguser",
|
||||||
|
};
|
||||||
|
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||||
|
.ReturnsAsync(existingUser);
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
Func<Task<RegistrationPayload>> act = async () =>
|
||||||
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should()
|
||||||
|
.ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("Username or email already exists");
|
||||||
|
|
||||||
|
_authRepoMock.Verify(
|
||||||
|
x =>
|
||||||
|
x.RegisterUserAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<DateTime>(),
|
||||||
|
It.IsAny<string>()
|
||||||
|
),
|
||||||
|
Times.Never
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
||||||
|
{
|
||||||
|
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
||||||
|
UserAccount existingUser = new()
|
||||||
|
{
|
||||||
|
UserAccountId = Guid.NewGuid(),
|
||||||
|
Email = "existing@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
|
||||||
|
|
||||||
|
Func<Task<RegistrationPayload>> act = async () =>
|
||||||
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should()
|
||||||
|
.ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("Username or email already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
||||||
|
{
|
||||||
|
RegisterUserCommand command = ValidCommand();
|
||||||
|
const string hashedPassword = "hashed_secure_password";
|
||||||
|
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||||
|
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x =>
|
||||||
|
x.RegisterUserAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<DateTime>(),
|
||||||
|
hashedPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("access-token");
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("refresh-token");
|
||||||
|
|
||||||
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
|
||||||
|
_authRepoMock.Verify(
|
||||||
|
x =>
|
||||||
|
x.RegisterUserAsync(
|
||||||
|
command.Username,
|
||||||
|
command.FirstName,
|
||||||
|
command.LastName,
|
||||||
|
command.Email,
|
||||||
|
command.DateOfBirth,
|
||||||
|
hashedPassword
|
||||||
|
),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
|
||||||
|
{
|
||||||
|
RegisterUserCommand command = ValidCommand();
|
||||||
|
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x =>
|
||||||
|
x.RegisterUserAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<DateTime>(),
|
||||||
|
It.IsAny<string>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(
|
||||||
|
new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountId = Guid.NewGuid(),
|
||||||
|
Username = command.Username,
|
||||||
|
Email = command.Email,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("access-token");
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("refresh-token");
|
||||||
|
_mediatorMock
|
||||||
|
.Setup(x =>
|
||||||
|
x.Send(It.IsAny<SendRegistrationEmailCommand>(), It.IsAny<CancellationToken>())
|
||||||
|
)
|
||||||
|
.ThrowsAsync(new Exception("smtp down"));
|
||||||
|
|
||||||
|
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
|
result.ConfirmationEmailSent.Should().BeFalse();
|
||||||
|
result.AccessToken.Should().Be("access-token");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using MediatR;
|
||||||
|
using Moq;
|
||||||
|
using Shared.Application.Emails;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Commands;
|
||||||
|
|
||||||
|
public class ResendConfirmationEmailHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
|
||||||
|
private readonly ResendConfirmationEmailHandler _handler;
|
||||||
|
private readonly Mock<IMediator> _mediatorMock = new();
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
||||||
|
|
||||||
|
public ResendConfirmationEmailHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new ResendConfirmationEmailHandler(
|
||||||
|
_authRepositoryMock.Object,
|
||||||
|
_tokenServiceMock.Object,
|
||||||
|
_mediatorMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||||
|
{
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
UserAccount user = new()
|
||||||
|
{
|
||||||
|
UserAccountId = userId,
|
||||||
|
FirstName = "Aaron",
|
||||||
|
Email = "aaron@example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||||
|
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
|
||||||
|
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user)).Returns("fresh-token");
|
||||||
|
|
||||||
|
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
|
_mediatorMock.Verify(
|
||||||
|
x =>
|
||||||
|
x.Send(
|
||||||
|
It.Is<SendResendConfirmationEmailCommand>(c =>
|
||||||
|
c.FirstName == "Aaron"
|
||||||
|
&& c.Email == "aaron@example.com"
|
||||||
|
&& c.ConfirmationToken == "fresh-token"
|
||||||
|
),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
||||||
|
{
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
|
_mediatorMock.Verify(
|
||||||
|
x =>
|
||||||
|
x.Send(
|
||||||
|
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
),
|
||||||
|
Times.Never
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
||||||
|
{
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
UserAccount user = new() { UserAccountId = userId };
|
||||||
|
|
||||||
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||||
|
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
|
||||||
|
|
||||||
|
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
|
_mediatorMock.Verify(
|
||||||
|
x =>
|
||||||
|
x.Send(
|
||||||
|
It.IsAny<SendResendConfirmationEmailCommand>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
),
|
||||||
|
Times.Never
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<RootNamespace>Features.Auth.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||||
|
<PackageReference Include="DbMocker" Version="1.26.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Auth.Queries.Login;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.PasswordHashing;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Queries;
|
||||||
|
|
||||||
|
public class LoginHandlerTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||||
|
private readonly LoginHandler _handler;
|
||||||
|
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
|
||||||
|
public LoginHandlerTests()
|
||||||
|
{
|
||||||
|
_authRepoMock = new Mock<IAuthRepository>();
|
||||||
|
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||||
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
_handler = new LoginHandler(
|
||||||
|
_authRepoMock.Object,
|
||||||
|
_passwordInfraMock.Object,
|
||||||
|
_tokenServiceMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithValidData_ReturnsPayloadWithMatchingUsername()
|
||||||
|
{
|
||||||
|
const string username = "CogitoErgoSum";
|
||||||
|
Guid userAccountId = Guid.NewGuid();
|
||||||
|
|
||||||
|
UserAccount userAccount = new()
|
||||||
|
{
|
||||||
|
UserAccountId = userAccountId,
|
||||||
|
Username = username,
|
||||||
|
FirstName = "René",
|
||||||
|
LastName = "Descartes",
|
||||||
|
Email = "r.descartes@example.com",
|
||||||
|
DateOfBirth = new DateTime(1596, 03, 31),
|
||||||
|
};
|
||||||
|
|
||||||
|
UserCredential userCredential = new()
|
||||||
|
{
|
||||||
|
UserCredentialId = Guid.NewGuid(),
|
||||||
|
UserAccountId = userAccountId,
|
||||||
|
Hash = "some-hash",
|
||||||
|
Expiry = DateTime.MaxValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||||
|
.ReturnsAsync(userCredential);
|
||||||
|
_passwordInfraMock
|
||||||
|
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Returns(true);
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("access-token");
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||||
|
.Returns("refresh-token");
|
||||||
|
|
||||||
|
LoginPayload result = await _handler.Handle(
|
||||||
|
new LoginQuery(username, "any-password"),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserAccountId.Should().Be(userAccountId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
result.AccessToken.Should().Be("access-token");
|
||||||
|
result.RefreshToken.Should().Be("refresh-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
const string username = "de_beauvoir";
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetUserByUsernameAsync(username))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
|
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
_authRepoMock.Verify(
|
||||||
|
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
|
||||||
|
Times.Never
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
const string username = "BRussell";
|
||||||
|
Guid userAccountId = Guid.NewGuid();
|
||||||
|
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||||
|
|
||||||
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||||
|
.ReturnsAsync((UserCredential?)null);
|
||||||
|
|
||||||
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
|
await _handler.Handle(new LoginQuery(username, "password"), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("Invalid username or password.");
|
||||||
|
_passwordInfraMock.Verify(
|
||||||
|
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||||
|
Times.Never
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
const string username = "RCarnap";
|
||||||
|
Guid userAccountId = Guid.NewGuid();
|
||||||
|
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||||
|
UserCredential userCredential = new()
|
||||||
|
{
|
||||||
|
UserCredentialId = Guid.NewGuid(),
|
||||||
|
UserAccountId = userAccountId,
|
||||||
|
Hash = "hashed-password",
|
||||||
|
};
|
||||||
|
|
||||||
|
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||||
|
_authRepoMock
|
||||||
|
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||||
|
.ReturnsAsync(userCredential);
|
||||||
|
_passwordInfraMock
|
||||||
|
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
|
await _handler.Handle(
|
||||||
|
new LoginQuery(username, "wrong-password"),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
await act.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("Invalid username or password.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
using Apps72.Dev.Data.DbMocker;
|
using Apps72.Dev.Data.DbMocker;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Features.Auth.Repository;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Repository.Auth;
|
|
||||||
using Infrastructure.Repository.Tests.Database;
|
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Tests.Auth;
|
namespace Features.Auth.Tests.Repository;
|
||||||
|
|
||||||
public class AuthRepositoryTest
|
public class AuthRepositoryTests
|
||||||
{
|
{
|
||||||
private static AuthRepository CreateRepo(MockDbConnection conn) =>
|
private static AuthRepository CreateRepo(MockDbConnection conn)
|
||||||
new(new TestConnectionFactory(conn));
|
{
|
||||||
|
return new AuthRepository(new TestConnectionFactory(conn));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
|
public async Task RegisterUserAsync_CreatesUserWithCredential_ReturnsUserAccount()
|
||||||
{
|
{
|
||||||
var expectedUserId = Guid.NewGuid();
|
Guid expectedUserId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser")
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_RegisterUser").ReturnsScalar(expectedUserId);
|
||||||
.ReturnsScalar(expectedUserId);
|
|
||||||
|
|
||||||
// Mock the subsequent read for the newly created user by id
|
// Mock the subsequent read for the newly created user by id
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountById")
|
||||||
@@ -47,14 +48,14 @@ public class AuthRepositoryTest
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.RegisterUserAsync(
|
UserAccount result = await repo.RegisterUserAsync(
|
||||||
username: "testuser",
|
"testuser",
|
||||||
firstName: "Test",
|
"Test",
|
||||||
lastName: "User",
|
"User",
|
||||||
email: "test@example.com",
|
"test@example.com",
|
||||||
dateOfBirth: new DateTime(1990, 1, 1),
|
new DateTime(1990, 1, 1),
|
||||||
passwordHash: "hashedpassword123"
|
"hashedpassword123"
|
||||||
);
|
);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
@@ -69,8 +70,8 @@ public class AuthRepositoryTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
|
public async Task GetUserByEmailAsync_ReturnsUser_WhenExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||||
.ReturnsTable(
|
.ReturnsTable(
|
||||||
@@ -99,8 +100,8 @@ public class AuthRepositoryTest
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
UserAccount? result = await repo.GetUserByEmailAsync("emailuser@example.com");
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.UserAccountId.Should().Be(userId);
|
result!.UserAccountId.Should().Be(userId);
|
||||||
@@ -113,13 +114,13 @@ public class AuthRepositoryTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
|
public async Task GetUserByEmailAsync_ReturnsNull_WhenNotExists()
|
||||||
{
|
{
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByEmail")
|
||||||
.ReturnsTable(MockTable.Empty());
|
.ReturnsTable(MockTable.Empty());
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
UserAccount? result = await repo.GetUserByEmailAsync("nonexistent@example.com");
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
@@ -127,12 +128,10 @@ public class AuthRepositoryTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
|
public async Task GetUserByUsernameAsync_ReturnsUser_WhenExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
|
||||||
)
|
|
||||||
.ReturnsTable(
|
.ReturnsTable(
|
||||||
MockTable
|
MockTable
|
||||||
.WithColumns(
|
.WithColumns(
|
||||||
@@ -159,8 +158,8 @@ public class AuthRepositoryTest
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByUsernameAsync("usernameuser");
|
UserAccount? result = await repo.GetUserByUsernameAsync("usernameuser");
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.UserAccountId.Should().Be(userId);
|
result!.UserAccountId.Should().Be(userId);
|
||||||
@@ -171,15 +170,13 @@ public class AuthRepositoryTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
|
public async Task GetUserByUsernameAsync_ReturnsNull_WhenNotExists()
|
||||||
{
|
{
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd => cmd.CommandText == "usp_GetUserAccountByUsername")
|
||||||
cmd.CommandText == "usp_GetUserAccountByUsername"
|
|
||||||
)
|
|
||||||
.ReturnsTable(MockTable.Empty());
|
.ReturnsTable(MockTable.Empty());
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetUserByUsernameAsync("nonexistent");
|
UserAccount? result = await repo.GetUserByUsernameAsync("nonexistent");
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
@@ -187,13 +184,11 @@ public class AuthRepositoryTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
|
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsCredential_WhenExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var credentialId = Guid.NewGuid();
|
Guid credentialId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
||||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
|
||||||
)
|
|
||||||
.ReturnsTable(
|
.ReturnsTable(
|
||||||
MockTable
|
MockTable
|
||||||
.WithColumns(
|
.WithColumns(
|
||||||
@@ -203,17 +198,11 @@ public class AuthRepositoryTest
|
|||||||
("CreatedAt", typeof(DateTime)),
|
("CreatedAt", typeof(DateTime)),
|
||||||
("Timer", typeof(byte[]))
|
("Timer", typeof(byte[]))
|
||||||
)
|
)
|
||||||
.AddRow(
|
.AddRow(credentialId, userId, "hashed_password_value", DateTime.UtcNow, null)
|
||||||
credentialId,
|
|
||||||
userId,
|
|
||||||
"hashed_password_value",
|
|
||||||
DateTime.UtcNow,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||||
|
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result!.UserCredentialId.Should().Be(credentialId);
|
result!.UserCredentialId.Should().Be(credentialId);
|
||||||
@@ -224,16 +213,14 @@ public class AuthRepositoryTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
|
public async Task GetActiveCredentialByUserAccountIdAsync_ReturnsNull_WhenNotExists()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd =>
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId")
|
||||||
cmd.CommandText == "USP_GetActiveUserCredentialByUserAccountId"
|
|
||||||
)
|
|
||||||
.ReturnsTable(MockTable.Empty());
|
.ReturnsTable(MockTable.Empty());
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
var result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
UserCredential? result = await repo.GetActiveCredentialByUserAccountIdAsync(userId);
|
||||||
|
|
||||||
result.Should().BeNull();
|
result.Should().BeNull();
|
||||||
}
|
}
|
||||||
@@ -241,18 +228,16 @@ public class AuthRepositoryTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RotateCredentialAsync_ExecutesSuccessfully()
|
public async Task RotateCredentialAsync_ExecutesSuccessfully()
|
||||||
{
|
{
|
||||||
var userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
var newPasswordHash = "new_hashed_password";
|
string newPasswordHash = "new_hashed_password";
|
||||||
var conn = new MockDbConnection();
|
MockDbConnection conn = new();
|
||||||
|
|
||||||
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential")
|
conn.Mocks.When(cmd => cmd.CommandText == "USP_RotateUserCredential").ReturnsScalar(1);
|
||||||
.ReturnsScalar(1);
|
|
||||||
|
|
||||||
var repo = CreateRepo(conn);
|
AuthRepository repo = CreateRepo(conn);
|
||||||
|
|
||||||
// Should not throw
|
// Should not throw
|
||||||
var act = async () =>
|
Func<Task> act = async () => await repo.RotateCredentialAsync(userId, newPasswordHash);
|
||||||
await repo.RotateCredentialAsync(userId, newPasswordHash);
|
|
||||||
await act.Should().NotThrowAsync();
|
await act.Should().NotThrowAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using Infrastructure.Repository.Sql;
|
using Infrastructure.Sql;
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Tests.Database;
|
namespace Features.Auth.Tests.Repository;
|
||||||
|
|
||||||
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
internal class TestConnectionFactory(DbConnection conn) : ISqlConnectionFactory
|
||||||
{
|
{
|
||||||
private readonly DbConnection _conn = conn;
|
private readonly DbConnection _conn = conn;
|
||||||
|
|
||||||
public DbConnection CreateConnection() => _conn;
|
public DbConnection CreateConnection()
|
||||||
|
{
|
||||||
|
return _conn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Services;
|
||||||
|
|
||||||
|
public class TokenServiceRefreshTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
|
public TokenServiceRefreshTests()
|
||||||
|
{
|
||||||
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
|
// Set environment variables for tokens
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
"ACCESS_TOKEN_SECRET",
|
||||||
|
"test-access-secret-that-is-very-long-1234567890"
|
||||||
|
);
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
"REFRESH_TOKEN_SECRET",
|
||||||
|
"test-refresh-secret-that-is-very-long-1234567890"
|
||||||
|
);
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
"CONFIRMATION_TOKEN_SECRET",
|
||||||
|
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||||
|
);
|
||||||
|
|
||||||
|
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
UserAccount userAccount = new()
|
||||||
|
{
|
||||||
|
UserAccountId = userId,
|
||||||
|
Username = username,
|
||||||
|
FirstName = "Test",
|
||||||
|
LastName = "User",
|
||||||
|
Email = "test@example.com",
|
||||||
|
DateOfBirth = new DateTime(1990, 1, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the validation of refresh token
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Mock the generation of new tokens
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||||
|
.Returns(
|
||||||
|
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
|
||||||
|
);
|
||||||
|
|
||||||
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserAccount.UserAccountId.Should().Be(userId);
|
||||||
|
result.UserAccount.Username.Should().Be(username);
|
||||||
|
result.AccessToken.Should().NotBeEmpty();
|
||||||
|
result.RefreshToken.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
_authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
|
||||||
|
|
||||||
|
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||||
|
_tokenInfraMock.Verify(
|
||||||
|
x =>
|
||||||
|
x.GenerateJwt(
|
||||||
|
It.IsAny<Guid>(),
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<DateTime>(),
|
||||||
|
It.IsAny<string>()
|
||||||
|
),
|
||||||
|
Times.Exactly(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string invalidToken = "invalid-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.RefreshTokenAsync(invalidToken))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string expiredToken = "expired-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.RefreshTokenAsync(expiredToken))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.RefreshTokenAsync(refreshToken))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Features.Auth.Tests.Services;
|
||||||
|
|
||||||
|
public class TokenServiceValidationTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
|
public TokenServiceValidationTests()
|
||||||
|
{
|
||||||
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
|
// Set environment variables for tokens
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
"ACCESS_TOKEN_SECRET",
|
||||||
|
"test-access-secret-that-is-very-long-1234567890"
|
||||||
|
);
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
"REFRESH_TOKEN_SECRET",
|
||||||
|
"test-refresh-secret-that-is-very-long-1234567890"
|
||||||
|
);
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
"CONFIRMATION_TOKEN_SECRET",
|
||||||
|
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||||
|
);
|
||||||
|
|
||||||
|
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-access-token";
|
||||||
|
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ValidatedToken result = await _tokenService.ValidateAccessTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
result.Principal.Should().NotBeNull();
|
||||||
|
result
|
||||||
|
.Principal.FindFirst(JwtRegisteredClaimNames.Sub)
|
||||||
|
?.Value.Should()
|
||||||
|
.Be(userId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-refresh-token";
|
||||||
|
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ValidatedToken result = await _tokenService.ValidateRefreshTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-confirmation-token";
|
||||||
|
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
ValidatedToken result = await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "expired-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Token has expired"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "token-without-user-id";
|
||||||
|
|
||||||
|
// Claims without Sub (user ID)
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*missing required claims*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Guid userId = Guid.NewGuid();
|
||||||
|
const string token = "token-without-username";
|
||||||
|
|
||||||
|
// Claims without UniqueName (username)
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*missing required claims*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "token-with-malformed-user-id";
|
||||||
|
|
||||||
|
// Claims with invalid GUID format
|
||||||
|
List<Claim> claims = new()
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||||
|
new Claim(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.ValidateAccessTokenAsync(token))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*malformed user ID*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.ValidateRefreshTokenAsync(token))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-confirmation-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions
|
||||||
|
.Invoking(async () => await _tokenService.ValidateConfirmationTokenAsync(token))
|
||||||
|
.Should()
|
||||||
|
.ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,38 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Auth.Commands.ConfirmUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||||
|
/// corresponding user account as confirmed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authRepository">Repository used to look up and confirm user accounts.</param>
|
||||||
|
/// <param name="tokenService">Service used to validate the confirmation token.</param>
|
||||||
|
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
|
||||||
|
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||||
|
{
|
||||||
|
/// <exception cref="UnauthorizedException">
|
||||||
|
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<ConfirmationPayload> Handle(
|
||||||
|
ConfirmUserCommand request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ValidatedToken validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||||
|
request.Token
|
||||||
|
);
|
||||||
|
|
||||||
|
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
throw new UnauthorizedException("User account not found");
|
||||||
|
|
||||||
|
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,28 @@
|
|||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new
|
||||||
|
/// access/refresh token pair.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokenService">Service used to validate and exchange the refresh token.</param>
|
||||||
|
public class RefreshTokenHandler(ITokenService tokenService)
|
||||||
|
: IRequestHandler<RefreshTokenCommand, LoginPayload>
|
||||||
|
{
|
||||||
|
public async Task<LoginPayload> Handle(
|
||||||
|
RefreshTokenCommand request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||||
|
return new LoginPayload(
|
||||||
|
result.UserAccount.UserAccountId,
|
||||||
|
result.UserAccount.Username,
|
||||||
|
result.RefreshToken,
|
||||||
|
result.AccessToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="RefreshTokenCommand" /> instances before they are processed.
|
||||||
|
/// </summary>
|
||||||
|
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures a validation rule requiring <see cref="RefreshTokenCommand.RefreshToken" /> to be non-empty.
|
||||||
|
/// </summary>
|
||||||
|
public RefreshTokenValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RefreshToken).NotEmpty().WithMessage("Refresh token is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Features.Auth.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Username">
|
||||||
|
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
|
||||||
|
/// underscores, and hyphens.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
|
||||||
|
/// <param name="LastName">The user's last name; up to 128 characters.</param>
|
||||||
|
/// <param name="Email">The user's email address; up to 128 characters and must be a valid email format.</param>
|
||||||
|
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
||||||
|
/// <param name="Password">
|
||||||
|
/// The desired plaintext password; must be at least 8 characters and contain an uppercase letter, a
|
||||||
|
/// lowercase letter, a number, and a special character.
|
||||||
|
/// </param>
|
||||||
|
public record RegisterUserCommand(
|
||||||
|
string Username,
|
||||||
|
string FirstName,
|
||||||
|
string LastName,
|
||||||
|
string Email,
|
||||||
|
DateTime DateOfBirth,
|
||||||
|
string Password
|
||||||
|
) : IRequest<RegistrationPayload>;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Features.Auth.Dtos;
|
||||||
|
using Features.Auth.Repository;
|
||||||
|
using Features.Auth.Services;
|
||||||
|
using Infrastructure.PasswordHashing;
|
||||||
|
using MediatR;
|
||||||
|
using Shared.Application.Emails;
|
||||||
|
|
||||||
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
|
||||||
|
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
||||||
|
/// confirmation email via Features.Emails.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authRepo">Repository used to check for existing users and persist the new account.</param>
|
||||||
|
/// <param name="passwordInfrastructure">Infrastructure component used to hash the user's plain-text password.</param>
|
||||||
|
/// <param name="tokenService">Service used to generate access, refresh, and confirmation tokens.</param>
|
||||||
|
/// <param name="mediator">Used to send the cross-slice command that triggers the confirmation email.</param>
|
||||||
|
public class RegisterUserHandler(
|
||||||
|
IAuthRepository authRepo,
|
||||||
|
IPasswordInfrastructure passwordInfrastructure,
|
||||||
|
ITokenService tokenService,
|
||||||
|
IMediator mediator
|
||||||
|
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
||||||
|
{
|
||||||
|
/// <exception cref="ConflictException">
|
||||||
|
/// Thrown when an existing account already has the same username or email address.
|
||||||
|
/// </exception>
|
||||||
|
public async Task<RegistrationPayload> Handle(
|
||||||
|
RegisterUserCommand request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await ValidateUserDoesNotExist(request.Username, request.Email);
|
||||||
|
|
||||||
|
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||||
|
|
||||||
|
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||||
|
request.Username,
|
||||||
|
request.FirstName,
|
||||||
|
request.LastName,
|
||||||
|
request.Email,
|
||||||
|
request.DateOfBirth,
|
||||||
|
hashed
|
||||||
|
);
|
||||||
|
|
||||||
|
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||||
|
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||||
|
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||||
|
return new RegistrationPayload(
|
||||||
|
createdUser.UserAccountId,
|
||||||
|
createdUser.Username,
|
||||||
|
string.Empty,
|
||||||
|
string.Empty,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
bool emailSent = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await mediator.Send(
|
||||||
|
new SendRegistrationEmailCommand(
|
||||||
|
createdUser.FirstName,
|
||||||
|
createdUser.Email,
|
||||||
|
confirmationToken
|
||||||
|
),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
emailSent = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await Console.Error.WriteLineAsync(ex.Message);
|
||||||
|
Console.WriteLine("Could not send email.");
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RegistrationPayload(
|
||||||
|
createdUser.UserAccountId,
|
||||||
|
createdUser.Username,
|
||||||
|
refreshToken,
|
||||||
|
accessToken,
|
||||||
|
emailSent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <exception cref="ConflictException">
|
||||||
|
/// Thrown when an existing account already has the same username or email address.
|
||||||
|
/// </exception>
|
||||||
|
private async Task ValidateUserDoesNotExist(string username, string email)
|
||||||
|
{
|
||||||
|
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||||
|
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||||
|
|
||||||
|
if (existingUsername != null || existingEmail != null)
|
||||||
|
throw new ConflictException("Username or email already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,17 @@
|
|||||||
using API.Core.Contracts.Common;
|
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace API.Core.Contracts.Auth;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
public record RegisterRequest(
|
/// <summary>
|
||||||
string Username,
|
/// Validates <see cref="RegisterUserCommand" /> instances before they are processed.
|
||||||
string FirstName,
|
/// </summary>
|
||||||
string LastName,
|
public class RegisterUserValidator : AbstractValidator<RegisterUserCommand>
|
||||||
string Email,
|
|
||||||
DateTime DateOfBirth,
|
|
||||||
string Password
|
|
||||||
);
|
|
||||||
|
|
||||||
public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
|
||||||
{
|
{
|
||||||
public RegisterRequestValidator()
|
/// <summary>
|
||||||
|
/// Configures validation rules for username format and length, first/last name length, email format and
|
||||||
|
/// length, minimum age based on date of birth, and password strength requirements.
|
||||||
|
/// </summary>
|
||||||
|
public RegisterUserValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Username)
|
RuleFor(x => x.Username)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
@@ -64,8 +61,6 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
|
|||||||
.Matches("[0-9]")
|
.Matches("[0-9]")
|
||||||
.WithMessage("Password must contain at least one number")
|
.WithMessage("Password must contain at least one number")
|
||||||
.Matches("[^a-zA-Z0-9]")
|
.Matches("[^a-zA-Z0-9]")
|
||||||
.WithMessage(
|
.WithMessage("Password must contain at least one special character");
|
||||||
"Password must contain at least one special character"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user