Update docs formatting

This commit is contained in:
Aaron Po
2026-09-01 00:07:05 -04:00
parent 52910a17d5
commit dee38fd1a3
18 changed files with 292 additions and 282 deletions

View File

@@ -45,12 +45,15 @@ Authentication workflow diagrams, one per flow, under
### Vertical-slice architecture pattern
The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Users`, `Features.Breweries`,
`Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) is a single
project that owns its own MediatR commands/queries/handlers, validators, and
repository end to end. `Features.Emails`, `Features.Locations`, and
`Features.PhotoUpload` have no `Controllers/` folder; all three are invoked
internally by other slices via MediatR/DI, never over HTTP:
technical layers.
Each feature (`Features.Users`, `Features.Breweries`, `Features.Emails`,
`Features.Locations`, `Features.PhotoUpload`) is a single project that owns its
own MediatR commands/queries/handlers, validators, and repository end to end.
`Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no
`Controllers/` folder; all three are invoked internally by other slices via
MediatR/DI, never over HTTP:
```mermaid
flowchart TB
@@ -109,14 +112,6 @@ flowchart TB
style S5 stroke-dasharray: 4 3
```
Slices generally don't reference each other's project. Most cross-slice
interactions go through a MediatR command instead — for example,
`Features.Users` triggering a confirmation email handled by `Features.Emails`
via a `SendRegistrationEmailCommand` whose contract lives in
`Shared.Application`, so neither slice takes a project reference on the other.
The one exception is `Features.Users`, which takes a direct `ProjectReference`
on `Features.PhotoUpload` to send `UploadPhotoCommand` for avatar uploads.
### Layer responsibilities
#### API layer (`API.Core`)
@@ -163,12 +158,6 @@ own
`Features.Emails` has no `Controllers/` folder. It's invoked only via MediatR
commands sent from other slices, never over HTTP.
`Features.Locations` also has no `Controllers/` folder and no MediatR handlers;
it exposes `ILocationRepository` directly as a plain service, currently consumed
only by `Database.Seed` to look up cities while seeding brewery locations. Other
slices (for example, `Features.Breweries`) do not yet depend on it; they run
their own `CityID` existence checks inline.
`Features.PhotoUpload` also has no `Controllers/` folder; its
`UploadPhotoCommand` is sent by other features' handlers (for example, a future
brewery photo upload command) rather than bound to an HTTP route directly.
@@ -182,8 +171,8 @@ brewery photo upload command) rather than bound to an HTTP route directly.
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with one exception: `Features.Users`
references `Features.PhotoUpload` directly for avatar uploads
- No other `Features.*` project, with one exception: `Features.Users` references
`Features.PhotoUpload` directly for avatar uploads
**Rules**:
@@ -277,13 +266,15 @@ everything needed to understand or change one capability lives in one project
- Each HTTP write operation is a `Command` (for example, `CreateBreweryCommand`
in `Features.Breweries/Commands/CreateBrewery/`); each read operation is a
`Query` (for example, `GetBreweryByIdQuery`)
- Controllers bind directly to the Command/Query as the request body; there is
no separate request DTO + mapping step for writes
- Controllers generally bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes
- The exception to this is for authenticated routes, when the current user id
needs to be extracted from the authentication token to be used in a command.
- 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
leak
**Example**:
@@ -351,16 +342,11 @@ method that registers its repository and slice-internal services
**Strategy**:
- No ORM; each repository issues inline SQL through Dapper, which handles
parameter binding. The one wrinkle is the `GEOGRAPHY` column on
`BreweryPostLocation`, which Dapper can't deserialize as a UDT: reads select
`CONVERT(varbinary(max), Coordinates)` so the value comes back as a plain
`byte[]` Dapper can bind. Proximity search
(`GET /api/brewery/locations/nearby`) filters and orders rows server-side with
`Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding a client-side
distance calculation over every row. No spatial index backs the query yet;
`schema.sql` sketches one, commented out, for when the table grows large
enough to need it
- Each repository issues inline SQL through Dapper, which handles parameter
binding.
- Proximity search (`GET /api/brewery/locations/nearby`) filters and orders rows
server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`, avoiding
a client-side distance calculation over every row.
- Referential checks (for example, "does this `CityId` exist?") are explicit
`SELECT 1 ...` existence checks in the repository method, run inside the same
transaction as the write
@@ -372,8 +358,8 @@ method that registers its repository and slice-internal services
- Application focuses on orchestration; the database enforces integrity via
keys, `CHECK` constraints, and cascades
See [Database](database.md) for the schema and the app's SQL
error-handling approach in more detail.
See [Database](database.md) for the schema and the app's SQL error-handling
approach in more detail.
## Frontend architecture
@@ -441,43 +427,25 @@ reference. Product and engineering documentation points to `web/frontend`.
### Authentication flow
1. **Registration**:
- User submits credentials
- Password hashed with Argon2id
- User account created (unverified) and a confirmation email is dispatched
via `Features.Emails`
2. **Email confirmation**:
- User follows the confirmation link/token from the email
- `Features.Users`' `ConfirmUser` command marks the account verified
3. **Login**:
- User submits credentials
- Password verified against hash
- Access and refresh JWTs are issued and returned to the client; neither is
stored server-side
4. **Token refresh**:
- Client exchanges a valid, unexpired refresh token for a new access/refresh
pair via `Features.Users`' `RefreshToken` command
- Refresh tokens are stateless JWTs, not tracked server-side: the previous
refresh token is not invalidated and remains usable until its own
expiration
5. **API Requests**:
- Client sends the access JWT in the `Authorization` header
- `JwtAuthenticationHandler` validates the token
- Request proceeds if valid
| Flow | Details |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Registration** | User submits credentials; password is hashed with Argon2id; user account is created as unverified and a confirmation email is dispatched via `Features.Emails`. |
| **Email confirmation** | User follows the confirmation link/token from the email; `Features.Users`' `ConfirmUser` command marks the account as verified. |
| **Login** | User submits credentials; password is verified against the hash; access and refresh JWTs are issued and returned to the client; neither is stored server-side. |
| **Token refresh** | Client exchanges a valid, unexpired refresh token for a new access/refresh pair via `Features.Users`' `RefreshToken` command; refresh tokens are stateless JWTs, not tracked server-side, and the previous refresh token remains usable until its own expiration. |
| **API requests** | Client sends the access JWT in the `Authorization` header; `JwtAuthenticationHandler` validates the token; request proceeds if valid. |
### Password security
**Algorithm**: Argon2id
- Memory: 64MB
- Iterations: 4
- Parallelism: 2 (hardcoded, to avoid thread-pool exhaustion)
- Salt: 128-bit (16 bytes)
- Hash: 256-bit (32 bytes)
| Setting | Value |
| ----------- | ---------------------------------------------- |
| Memory | 64MB |
| Iterations | 4 |
| Parallelism | 2 (hardcoded, to avoid thread-pool exhaustion) |
| Salt | 128-bit (16 bytes) |
| Hash | 256-bit (32 bytes) |
### JWT tokens
@@ -485,11 +453,13 @@ reference. Product and engineering documentation points to `web/frontend`.
**Claims**:
- `sub` - User ID
- `unique_name` - Username
- `jti` - Unique token ID
- `iat` - Issued at timestamp
- `exp` - Expiration timestamp
| Claim | Meaning |
| ------------- | -------------------- |
| `sub` | User ID |
| `unique_name` | Username |
| `jti` | Unique token ID |
| `iat` | Issued at timestamp |
| `exp` | Expiration timestamp |
**Configuration** (appsettings.json):
@@ -503,11 +473,10 @@ reference. Product and engineering documentation points to `web/frontend`.
}
```
This block exists in `appsettings.json`, but nothing in the code currently
reads it: actual token lifetimes come from the hardcoded
`TokenServiceExpirationHours` constants, and `JwtInfrastructure` hardcodes
`ValidateIssuer`/`ValidateAudience` to `false`, so `Issuer`/`Audience` are not
enforced.
This block exists in `appsettings.json`, but nothing in the code currently reads
it: actual token lifetimes come from the hardcoded `TokenServiceExpirationHours`
constants, and `JwtInfrastructure` hardcodes `ValidateIssuer`/`ValidateAudience`
to `false`, so `Issuer`/`Audience` are not enforced.
## Database architecture
@@ -563,7 +532,8 @@ tracking stays valid.
**Purpose**: Populate development/test databases with realistic data
**Implementation**: `Database.Seed` project, using data produced by the C++
pipeline under `tooling/pipeline/` (see [Pipeline README](../pipeline/README.md))
pipeline under `tooling/pipeline/` (see
[Pipeline README](../pipeline/README.md))
**Seed Data**:
@@ -573,11 +543,9 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](../pipeline/README.md)
- User accounts (via `Features.Users`' repository)
- Brewery posts with locations (via `Features.Breweries`' repository)
`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`,
but `Database.Seed` doesn't call it, so photos aren't part of the seed data.
Tables that exist in the schema but have no repository yet (`UserAvatar`,
`UserFollow`, `BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`,
`BreweryPostPhoto`) are not seeded either.
Tables that exist in the schema but have no repository yet ( `UserFollow`,
`BeerStyle`, `BeerPost`, `BeerPostPhoto`, `BeerPostComment`, `BreweryPostPhoto`)
are not seeded either.
## Deployment architecture
@@ -585,18 +553,22 @@ Tables that exist in the schema but have no repository yet (`UserAvatar`,
**Container Structure**:
- `sqlserver` - SQL Server 2022
- `database.migrations` - Schema migration runner
- `database.seed` - Data seeder
- `api.core` - ASP.NET Core Web API
- `mailpit` - Local dev/test SMTP server + web UI (not used in production)
- `seaweedfs` - S3-compatible object storage for uploaded photos (dev only)
| Container | Purpose |
| --------------------- | ------------------------------------------------------------ |
| `sqlserver` | SQL Server 2022 |
| `database.migrations` | Schema migration runner |
| `database.seed` | Data seeder |
| `api.core` | ASP.NET Core Web API |
| `mailpit` | Local dev/test SMTP server + web UI (not used in production) |
| `seaweedfs` | S3-compatible object storage for uploaded photos (dev only) |
**Environments**:
- Development (`docker-compose.dev.yaml`)
- Testing (`docker-compose.test.yaml`)
- Production (`docker-compose.prod.yaml`)
| Environment | Compose file |
| ----------- | -------------------------- |
| Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` |
For details, see [Docker Guide](docker.md).

View File

@@ -20,24 +20,24 @@ The full schema lives under
script per table.
```
└── 01-schema
├── 01-UserAccount.sql
├── 02-Photo.sql
├── 03-UserVerification.sql
├── 04-UserCredential.sql
├── 05-UserFollow.sql
├── 06-Country.sql
├── 07-StateProvince.sql
├── 08-City.sql
├── 09-BreweryPost.sql
├── 10-BreweryPostLocation.sql
├── 11-BreweryPostPhoto.sql
├── 12-BeerStyle.sql
├── 13-BeerPost.sql
├── 14-BeerPostPhoto.sql
├── 15-BeerPostComment.sql
├── 16-UserProfile.sql
└── 17-UserAvatar.sql
01-schema
├── 01-UserAccount.sql
├── 02-Photo.sql
├── 03-UserVerification.sql
├── 04-UserCredential.sql
├── 05-UserFollow.sql
├── 06-Country.sql
├── 07-StateProvince.sql
├── 08-City.sql
├── 09-BreweryPost.sql
├── 10-BreweryPostLocation.sql
├── 11-BreweryPostPhoto.sql
├── 12-BeerStyle.sql
├── 13-BeerPost.sql
├── 14-BeerPostPhoto.sql
├── 15-BeerPostComment.sql
├── 16-UserProfile.sql
└── 17-UserAvatar.sql
```
Each script is run in order by `Database.Migrations` (DbUp) on startup. Each

View File

@@ -1,6 +1,10 @@
# User Authentication Flow — Error Handling
Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`. Applies uniformly across every authentication endpoint (registration, login, refresh, confirm, resend, update-username/email/password/profile, delete-account) — see the per-flow diagrams for where each exception type is thrown.
Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`.
Applies uniformly across every authentication endpoint (registration, login,
refresh, confirm, resend, update-username/email/password/profile,
delete-account) — see the per-flow diagrams for where each exception type is
thrown.
```mermaid
--8<-- "web/diagrams/auth/error-handling.mmd"

View File

@@ -1,6 +1,8 @@
# Database Schema
Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency), omitted below for brevity. Tables marked `(*)` have no repository/feature slice yet.
Every table also has a `RowVersion ROWVERSION` column (optimistic concurrency),
omitted below for brevity. Tables marked `(*)` have no repository/feature slice
yet.
```mermaid
--8<-- "web/diagrams/database-schema.mmd"

View File

@@ -6,10 +6,12 @@
## Notes
- **Dev network** (`devnet`, bridge): internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed).
- **Test network** (`testnet`, bridge): all test components isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test`/`seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, results aggregated.
- **Prod network** (`prodnet`, bridge): isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production.
- **Database-only environment**: same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website.
- **Minimal environment**: database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker.
- `unit.tests` builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed.
- `.trx` results are readable by Visual Studio, Azure DevOps, and GitHub Actions.
| Environment | Details |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers; results are aggregated. |
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
| `.trx` results | Readable by Visual Studio, Azure DevOps, and GitHub Actions. |

View File

@@ -17,12 +17,12 @@ doesn't wire up any slice; it applies schema directly.
## Project reference dependencies
Solid arrows are compile-time project references. Dashed arrows are the two
places slices talk to each other without a direct reference: the email
MediatR contract routed through `Shared.Application`, and the one place that
pattern is broken outright. Every DB-backed slice depends on the same three
foundation projects (`Shared.Contracts`, `Domain.Entities`,
`Database.Connection`) — drawn as one box instead of fifteen separate arrows;
see the notes for what else each of those foundation projects pulls in.
places slices talk to each other without a direct reference: the email MediatR
contract routed through `Shared.Application`, and the one place that pattern is
broken outright. Every DB-backed slice depends on the same three foundation
projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
as one box instead of fifteen separate arrows; see the notes for what else each
of those foundation projects pulls in.
```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd"
@@ -33,32 +33,49 @@ see the notes for what else each of those foundation projects pulls in.
- **API.Core**: Program.cs wiring only
- MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly
- Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter
- Only `Features.Users` and `Features.Breweries` get `AddApplicationPart` (controllers)
- `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers only
- `Features.Locations` and `Features.Beers` not referenced by `API.Core` at all
- Only `Features.Users` and `Features.Breweries` get `AddApplicationPart`
(controllers)
- `Features.PhotoUpload` and `Features.Emails` scanned for MediatR handlers
only
- `Features.Locations` and `Features.Beers` not referenced by `API.Core` at
all
- **Feature slices**: each owns its own controller (where present), commands/queries + handlers, validators, and Dapper repository
- `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`)
- internally namespaced `Features.Auth`, despite project name `Features.Users`
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked only via MediatR commands sent from other slices
- `Features.Locations` has no controller; one MediatR handler (`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core` never scans it
- `Features.Beers` is scaffolded (project + references only, no source files yet)
- **Feature slices**: each owns its own controller (where present),
commands/queries + handlers, validators, and Dapper repository
- `Features.Users` covers auth (`Commands/Authentication`), account management
(`Commands/Account`), and profile/avatar (`Commands/Profile`)
- internally namespaced `Features.Auth`, despite project name
`Features.Users`
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked
only via MediatR commands sent from other slices
- `Features.Locations` has no controller; one MediatR handler
(`GetOrCreateCityHandler`) registered by `Database.Seed` only — `API.Core`
never scans it
- `Features.Beers` is scaffolded (project + references only, no source files
yet)
- **The one exception to slice isolation**: `Features.Users` takes a direct project reference to `Features.PhotoUpload`
- **The one exception to slice isolation**: `Features.Users` takes a direct
project reference to `Features.PhotoUpload`
- sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
- unlike the email flow, the command type lives in the target slice itself, not in `Shared.Application`
- unlike the email flow, the command type lives in the target slice itself,
not in `Shared.Application`
- **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline)
- Cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`)
- Cross-slice email commands (`SendRegistrationEmailCommand`,
`SendResendConfirmationEmailCommand`)
- MediatR command contract (not project reference)
- Every slice (including `Features.Emails`) references it for the pipeline behavior alone
- Every slice (including `Features.Emails`) references it for the pipeline
behavior alone
- **Domain.Entities**:
- `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`
- `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`,
`UserAvatar`
- `BreweryPost`, `BreweryPostLocation`
- `City`, `StateProvince`, `Country`
- `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for `BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no code)
- `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for
`BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no
code)
- **Database.Connection**: the shared connection-factory project
- each slice's own repository issues inline SQL via Dapper against it
@@ -68,7 +85,8 @@ see the notes for what else each of those foundation projects pulls in.
- **Database.Migrations**: separate console app
- `schema.dbml` + SQL scripts
- applies DDL directly
- distinct from `Database.Seed`, which loads data through the feature slices' own DI extensions
- distinct from `Database.Seed`, which loads data through the feature slices'
own DI extensions
- **Database.Seed**:
- calls each slice's `AddFeatures*()` DI extension directly
@@ -76,6 +94,10 @@ see the notes for what else each of those foundation projects pulls in.
- no API host, no shared pipeline with `API.Core`
- **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
- `Infrastructure.Configuration` (the shared options-binding project referenced by `Database.Connection`, `Infrastructure.Email`, `Infrastructure.FileUpload`, and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing them would just add a second copy of the same fan-in
- `Domain.Exceptions` (referenced by nearly every project directly, including
all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice)
- `Infrastructure.Configuration` (the shared options-binding project
referenced by `Database.Connection`, `Infrastructure.Email`,
`Infrastructure.FileUpload`, and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing
them would just add a second copy of the same fan-in

View File

@@ -21,8 +21,7 @@ The project uses Docker Compose to orchestrate multiple services:
- React Router website (SSR frontend)
- Test runners
See the [deployment diagram](diagrams/deployment.md) for visual
representation.
See the [deployment diagram](diagrams/deployment.md) for visual representation.
## Docker Compose environments

View File

@@ -308,9 +308,9 @@ cd web
See [Generating an env file](#generating-an-env-file) above — `generate-env.sh`
randomizes the secret-bearing values, so it's the preferred way to create any
`.env.dev`/`.env.test`/`.env.prod` file. A plain `cp web/.env.example
web/.env.dev` works too, but leaves the placeholder secrets from the template
in place; edit them manually if you go that route.
`.env.dev`/`.env.test`/`.env.prod` file. A plain
`cp web/.env.example web/.env.dev` works too, but leaves the placeholder secrets
from the template in place; edit them manually if you go that route.
**Docker Compose Mapping**:

View File

@@ -70,10 +70,12 @@ and SeaweedFS (local dev S3-compatible object storage for photo uploads).
Available endpoints:
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
- SeaweedFS S3 API: http://localhost:8333
| Service | URL |
| ---------------- | ----------------------------- |
| API Swagger | http://localhost:8080/swagger |
| Health Check | http://localhost:8080/health |
| Mailpit UI | http://localhost:8025 |
| SeaweedFS S3 API | http://localhost:8333 |
### 4. Start the frontend

View File

@@ -16,16 +16,12 @@ Biergarten App.
The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`), covering that slice's command/query handlers,
with dependencies mocked via Moq (no real database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared
website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
components
| Suite | Purpose |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **API.Specs** | BDD integration tests using Reqnroll (Gherkin), run against a live, seeded database |
| **Features.\*.Tests** | One unit test project per backend feature slice (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that slice's command/query handlers, with dependencies mocked via Moq (no real database required) |
| **Storybook Vitest project** | Browser-based interaction tests for shared website stories |
| **Storybook Playwright suite** | Browser checks against Storybook-rendered components |
## Running tests with Docker (recommended)
@@ -80,19 +76,6 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
You can run individual test projects locally without Docker:
### Integration tests (API.Specs)
```bash
cd web/backend
dotnet test API/API.Specs/API.Specs.csproj
```
**Requirements**:
- SQL Server instance running
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
### Feature slice unit tests
Each feature slice has its own test project, covering its command/query
@@ -122,6 +105,19 @@ npm install
npm run test:storybook
```
### Integration tests (API.Specs)
```bash
cd web/backend
dotnet test API/API.Specs/API.Specs.csproj
```
**Requirements**:
- SQL Server instance running
- Database migrated and seeded
- Environment variables set (DB connection, JWT secret)
**Purpose**:
- Verifies shared stories such as form fields, submit buttons, navbar states,

View File

@@ -12,11 +12,11 @@ tags:
The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
| Token Type | Lifetime | Purpose |
| --- | --- | --- |
| **Access Tokens** | 1 hour | API authentication |
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
| **Confirmation Tokens** | 30 minutes | Email confirmation |
| Token Type | Lifetime | Purpose |
| ----------------------- | ---------- | --------------------------- |
| **Access Tokens** | 1 hour | API authentication |
| **Refresh Tokens** | 21 days | Obtaining new access tokens |
| **Confirmation Tokens** | 30 minutes | Email confirmation |
## Components
@@ -48,16 +48,15 @@ Token generation and validation live on the same slice-internal service.
- `GenerateAccessToken(Guid userId, string username)` - Creates 1-hour access
token
- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day
refresh token
- `GenerateConfirmationToken(Guid userId, string username)` - Creates
30-minute confirmation token
- `GenerateRefreshToken(Guid userId, string username)` - Creates 21-day refresh
token
- `GenerateConfirmationToken(Guid userId, string username)` - Creates 30-minute
confirmation token
**Validation methods:**
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation
tokens
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
There is no `ValidateAccessTokenAsync` on `ITokenService`. Access-token
validation doesn't go through `ITokenService` at all: `JwtAuthenticationHandler`
@@ -181,43 +180,43 @@ Each token is validated for:
Validation failures return HTTP 401 Unauthorized. Regardless of the specific
cause (invalid signature, expired token, missing header, malformed claims),
`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same
generic message: `"Unauthorized: Invalid or missing authentication token"` —
it doesn't distinguish failure reasons in the response.
`JwtAuthenticationHandler.HandleChallengeAsync` always returns the same generic
message: `"Unauthorized: Invalid or missing authentication token"` — it doesn't
distinguish failure reasons in the response.
## Token lifecycle
### Access token lifecycle
| Step | Description |
| --- | --- |
| Generation | During login (1-hour validity) |
| Usage | Included in Authorization header on API requests |
| Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token |
| Step | Description |
| ---------- | ------------------------------------------------ |
| Generation | During login (1-hour validity) |
| Usage | Included in Authorization header on API requests |
| Validation | Validated on protected endpoints |
| Expiration | Token becomes invalid after 1 hour |
| Refresh | Use refresh token to obtain new access token |
### Refresh token lifecycle
| Step | Description |
| --- | --- |
| Generation | During login (21-day validity) |
| Storage | Client-side (secure storage) |
| Usage | Posted to `/api/auth/refresh` endpoint |
| Step | Description |
| ---------- | ------------------------------------------------ |
| Generation | During login (21-day validity) |
| Storage | Client-side (secure storage) |
| Usage | Posted to `/api/auth/refresh` endpoint |
| Validation | Validated by `ITokenService.RefreshTokenAsync()` |
| Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days |
| Rotation | New refresh token issued on successful refresh |
| Expiration | Token becomes invalid after 21 days |
### Confirmation token lifecycle
| Step | Description |
| --- | --- |
| Generation | During user registration (30-minute validity) |
| Delivery | Emailed to user in confirmation link |
| Usage | User clicks link, token posted to `/api/auth/confirm` |
| Validation | Validated by `ConfirmUserHandler` |
| Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes |
| Step | Description |
| ---------- | ----------------------------------------------------- |
| Generation | During user registration (30-minute validity) |
| Delivery | Emailed to user in confirmation link |
| Usage | User clicks link, token posted to `/api/auth/confirm` |
| Validation | Validated by `ConfirmUserHandler` |
| Completion | User account marked as confirmed |
| Expiration | Token becomes invalid after 30 minutes |
## Testing
@@ -277,22 +276,18 @@ All of the following live in `Features.Users.Tests`.
### Stretch goals
1. **Middleware for Access Token Validation**
- Automatically validate access tokens on protected routes
- Populate HttpContext.User from token claims
- Return 401 for invalid/missing tokens
1. **Middleware for Access Token Validation** - Automatically validate access
tokens on protected routes - Populate HttpContext.User from token claims -
Return 401 for invalid/missing tokens
2. **Token Denylisting**
- Implement token revocation (for example, on logout)
- Store denylisted tokens in cache/database
- Check the denylist during validation
2. **Token Denylisting** - Implement token revocation (for example, on logout) -
Store denylisted tokens in cache/database - Check the denylist during
validation
3. **Refresh Token Rotation Strategy**
- Detect token reuse (replay attacks)
- Automatically invalidate entire token chain on reuse
- Log suspicious activity
3. **Refresh Token Rotation Strategy** - Detect token reuse (replay attacks) -
Automatically invalidate entire token chain on reuse - Log suspicious
activity
4. **Structured Logging**
- Log token validation attempts
- Track failed validation reasons
- Alert on repeated validation failures (brute force detection)
4. **Structured Logging** - Log token validation attempts - Track failed
validation reasons - Alert on repeated validation failures (brute force
detection)