This commit is contained in:
Aaron Po
2026-08-31 18:33:20 -04:00
parent 30995f6f40
commit 772a65d896
11 changed files with 353 additions and 218 deletions

View File

@@ -1,4 +1,13 @@
# Database
---
title: SQL Server database schema, migrations, and error handling
last-updated: 2026-08-31
tags:
- sql
- repository
- database
- sql-server
- schema
---
SQL Server 2022 schema, migration approach, and the app's SQL error-handling
convention. For the vertical-slice code that talks to this schema, see
@@ -7,15 +16,36 @@ convention. For the vertical-slice code that talks to this schema, see
## Schema
The full schema lives under
`web/backend/Database/Database.Migrations/scripts/01-schema/`, one numbered
script per table (`01-UserAccount.sql`, `02-Photo.sql`, ...), run in order by
`Database.Migrations` (DbUp) on startup. Each table is queried directly by the
owning feature slice's repository (see
`web/backend/Database/Database.Migrations/scripts/01-schema/` with one numbered
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
```
Each script is run in order by `Database.Migrations` (DbUp) on startup. Each
table is queried directly by the owning feature slice's repository (see
[Direct SQL via Dapper/ADO.NET](../architecture.md#direct-sql-via-dapperadonet)).
A generated DBML copy of the schema (for import into a diagramming tool such
as dbdiagram.io) lives at
`web/backend/Database/Database.Migrations/schema.dbml`.
A generated DBML copy of the schema (for import into a diagramming tool such as
dbdiagram.io) lives at `web/backend/Database/Database.Migrations/schema.dbml`.
All tables use a `UNIQUEIDENTIFIER` primary key defaulting to `NEWID()`, and
most carry a `RowVersion ROWVERSION` column used for optimistic concurrency on
@@ -23,44 +53,52 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
### Tables backed by the API today
| Table | Backed by | Notes |
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
| Table | Backed by | Notes |
| --------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
### Tables defined in the schema but not yet wired to a feature slice
These exist so the schema can support planned functionality, but have no
repository or seed data yet:
| Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
| Table | Purpose (from schema) |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet |
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
| `BeerPostPhoto` | Links a `BeerPost` to a `Photo` |
| `BeerPostComment` | A comment + 1-5 `Rating` on a `BeerPost` |
| `BreweryPostPhoto` | Links a `BreweryPost` to a `Photo` |
## Migrations
## Managing Migrations (DbUp)
**Tool**: DbUp, run by the `Database.Migrations` project/container.
Migrations are handled by the `Database.Migrations` container using DbUp.
Scripts are placed in the `scripts/` directory, applied in order, and tracked so
that each one runs only once.
Scripts live under `scripts/`, applied in order and tracked so a script never
re-runs once it has succeeded. Today that's `01-schema/`, with one script per
table, numbered so a table's foreign keys always point at an earlier-numbered
script. When the schema needs to change, add a new numbered script rather than
editing an existing one in place: DbUp's tracking is per-script, so an edited
already-applied script never re-runs against existing databases.
**Note:** The schema is currently considered volatile. At this stage of
development, fresh database instances are provisioned for both development and
testing, so no production data exists yet.
### How to Make Schema Changes
- **Now (no production data):** Update the schema by editing the original DDL
statements directly.
- **Later (with production data):** Add a new, numbered script for any schema
changes. Never edit an existing script in place — DbUp tracks completion by
script name, so modifying an already-applied script means the changes will be
ignored on existing databases.
## Data seeding
@@ -81,7 +119,7 @@ repository (`Features.PhotoUpload`) but no seed data.
409, 400, ...); an unmapped `SqlException` (for example, a genuine
connectivity or constraint-violation failure) maps to 503.
- One exception: `Features.Users`' `DapperUserStore` does catch
`SqlException` directly, to detect a duplicate-key race (SQL Server error
2601 or 2627) when two requests try to verify the same account
concurrently; see `DapperUserStore.IsDuplicateKeyViolation`.
- One exception: `Features.Users`' `DapperUserStore` does catch `SqlException`
directly, to detect a duplicate-key race (SQL Server error 2601 or 2627)
when two requests try to verify the same account concurrently; see
`DapperUserStore.IsDuplicateKeyViolation`.

View File

@@ -1,4 +1,11 @@
# Docker guide
---
title: Docker deployment, configuration, and troubleshooting
last-updated: 2026-08-31
tags:
- docker
- docker-compose
- deployment
---
This document covers Docker deployment, configuration, and troubleshooting for
The Biergarten App.
@@ -114,9 +121,9 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
`wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
stopped, regardless of which one finishes first; `up --abort-on-container-exit`
isn't used here because the one-shot `database.migrations`/`database.seed`
jobs and `frontend.tests` (no database dependency) can exit before the other
test containers, which would end the run prematurely. See
isn't used here because the one-shot `database.migrations`/`database.seed` jobs
and `frontend.tests` (no database dependency) can exit before the other test
containers, which would end the run prematurely. See
[Testing](testing.md#running-tests-with-docker-recommended) for details.
### 3. Production (`docker-compose.prod.yaml`)
@@ -131,8 +138,8 @@ test containers, which would end the run prematurely. See
- Health checks enabled
- Restart policies (unless-stopped)
- Security hardening
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core`
are reachable only from other containers on `prodnet`
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
reachable only from other containers on `prodnet`
**Services**:
@@ -206,8 +213,8 @@ graph TD
E[seaweedfs: container started] --> D
```
In development, `api.core` also depends on `seaweedfs` (`condition:
service_started`, not a health check) alongside `database.seed`.
In development, `api.core` also depends on `seaweedfs`
(`condition: service_started`, not a health check) alongside `database.seed`.
**Health Check Example** (SQL Server):
@@ -241,8 +248,7 @@ api.core:
- `sqlserverdata-dev` - Database files persist between restarts
- `nuget-cache-dev` - NuGet package cache (speeds up builds)
- `seaweedfsdata-dev` - SeaweedFS object storage data persists between
restarts
- `seaweedfsdata-dev` - SeaweedFS object storage data persists between restarts
**Testing**:

View File

@@ -1,4 +1,12 @@
# Environment variables
---
title: Environment variables for the backend, frontend, and Docker
last-updated: 2026-08-31
tags:
- environment-variables
- configuration
- secrets
- docker
---
This document covers the environment variables used by the Biergarten stack.
@@ -37,9 +45,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`,
`SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`)
with freshly randomized ones via `openssl`. Non-secret values are left
untouched. The output file is written with `600` permissions.
`SEAWEEDFS_SECRET_ACCESS_KEY`) with freshly randomized ones via `openssl`.
Non-secret values are left untouched. The output file is written with `600`
permissions.
```bash
cd web
@@ -171,12 +179,12 @@ DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
```
`ASPNETCORE_URLS` is hardcoded directly in `docker-compose.dev.yaml` and
`docker-compose.prod.yaml` (not sourced from the `.env` file), so it's
required only for Docker. Outside Docker, `dotnet run` falls back to the
`http` profile in `API.Core/Properties/launchSettings.json`
(`http://localhost:5069`), so manual backend setup (see
[Getting Started](getting-started.md#manual-backend-setup)) doesn't need to
set it.
`docker-compose.prod.yaml` (not sourced from the `.env` file), so it's required
only for Docker. Outside Docker, `dotnet run` falls back to the `http` profile
in `API.Core/Properties/launchSettings.json` (`http://localhost:5069`), so
manual backend setup (see
[Getting Started](getting-started.md#manual-backend-setup)) doesn't need to set
it.
## Frontend variables (`web/frontend`)
@@ -191,8 +199,8 @@ PORT=3000 # Port the built server listens on
The `frontend` Docker service hardcodes `API_BASE_URL` to `http://api.core:8080`
(the backend's in-network service name) and `PORT` to `3000`; only
`SESSION_SECRET` is sourced from the env file, and `NODE_ENV` is set per
compose file (`development` in dev, `production` in prod).
`SESSION_SECRET` is sourced from the env file, and `NODE_ENV` is set per compose
file (`development` in dev, `production` in prod).
### Frontend variable details
@@ -357,8 +365,8 @@ Variables are validated at startup:
- Missing required variables (`ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`,
`CONFIRMATION_TOKEN_SECRET`, `SMTP_HOST`, `SMTP_FROM_EMAIL`,
`SEAWEEDFS_SERVICE_URL`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values)
cause the application to fail with an `InvalidOperationException`
`SEAWEEDFS_SECRET_ACCESS_KEY`, `SEAWEEDFS_BUCKET`, DB connection values) cause
the application to fail with an `InvalidOperationException`
- No minimum length is enforced on the token secrets in code; the "minimum 32
characters" guidance above is a recommendation, not an enforced check

View File

@@ -1,4 +1,11 @@
# Getting started
---
title: Getting started — local setup for backend and frontend
last-updated: 2026-08-31
tags:
- getting-started
- setup
- onboarding
---
This guide covers local setup for the Biergarten stack: the .NET backend in
`web/backend` and the React Router frontend in `web/frontend`.
@@ -58,9 +65,8 @@ section for details on `generate-env.sh`.
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
```
This starts SQL Server, migrations, seeding, the API, Mailpit (local dev
SMTP), and SeaweedFS (local dev S3-compatible object storage for photo
uploads).
This starts SQL Server, migrations, seeding, the API, Mailpit (local dev SMTP),
and SeaweedFS (local dev S3-compatible object storage for photo uploads).
Available endpoints:

View File

@@ -1,4 +1,13 @@
# Testing
---
title: Testing strategy — unit, integration, and Storybook suites
last-updated: 2026-08-31
tags:
- testing
- ci
- xunit
- reqnroll
- storybook
---
This document describes the testing strategy and how to run tests for The
Biergarten App.
@@ -10,10 +19,9 @@ 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)
(`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
@@ -42,8 +50,8 @@ This command:
`wait` blocks until `api.specs`, `unit.tests`, and `frontend.tests` have all
stopped, regardless of which one finishes first. Long-running services
(`sqlserver`, `seaweedfs`) and the one-shot `database.migrations` /
`database.seed` jobs keep running in the background alongside them and are
torn down separately (see below); `wait` doesn't touch them.
`database.seed` jobs keep running in the background alongside them and are torn
down separately (see below); `wait` doesn't touch them.
### View test results
@@ -175,8 +183,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
**Features.Breweries.Tests**:
- Brewery create/update/delete commands and get-by-id/get-all queries
- Brewery location queries: all locations, and locations within a given range
of a coordinate
- Brewery location queries: all locations, and locations within a given range of
a coordinate
**Features.Emails.Tests**:
@@ -186,9 +194,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- Upload command handler: storage key construction, content type passthrough,
and photo persistence via the mocked storage provider and repository
- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature
checks (including rejection of files with a mismatched or missing
signature)
- Upload validator: min/max file size bounds, and PNG/JPEG/WebP signature checks
(including rejection of files with a mismatched or missing signature)
**Frontend UI Coverage**:
@@ -295,8 +302,8 @@ Features.Users.Tests/
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds
a validator test file alongside its handler test).
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a
validator test file alongside its handler test).
## Writing tests
@@ -337,10 +344,10 @@ Scenario: User login with valid credentials
## Continuous integration
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push
and pull request to `main`. It runs the same test Docker Compose
configuration used locally, so backend and frontend tests execute in the same
containerized environment in CI as they do on a developer machine:
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push and
pull request to `main`. It runs the same test Docker Compose configuration used
locally, so backend and frontend tests execute in the same containerized
environment in CI as they do on a developer machine:
```bash
# CI/CD command
@@ -351,16 +358,15 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
The workflow inspects the exit code of each test container
(`test-env-api-specs`, `test-env-unit-tests`, `test-env-frontend-tests`)
after `wait` returns and fails the job if any of them is non-zero.
`./test-results/` is uploaded as a build artifact regardless of outcome.
(`test-env-api-specs`, `test-env-unit-tests`, `test-env-frontend-tests`) after
`wait` returns and fails the job if any of them is non-zero. `./test-results/`
is uploaded as a build artifact regardless of outcome.
`up -d` + `wait` keeps container teardown independent of any single
container's exit. The one-shot `database.migrations`/`database.seed` jobs
exit 0 partway through the run, and `frontend.tests` (which has no database
dependency) reliably finishes before `api.specs`/`unit.tests` start; because
`wait` blocks on all three test containers, neither early exit ends the run
prematurely.
`up -d` + `wait` keeps container teardown independent of any single container's
exit. The one-shot `database.migrations`/`database.seed` jobs exit 0 partway
through the run, and `frontend.tests` (which has no database dependency)
reliably finishes before `api.specs`/`unit.tests` start; because `wait` blocks
on all three test containers, neither early exit ends the run prematurely.
Exit codes:
@@ -368,9 +374,9 @@ Exit codes:
- Non-zero - Test failures occurred
The frontend's Storybook Vitest and Playwright suites run inside the
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that
same compose run, so no separate frontend CI step is needed. To run them
locally without Docker:
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that same
compose run, so no separate frontend CI step is needed. To run them locally
without Docker:
```bash
cd web/frontend
@@ -381,9 +387,9 @@ npm run test:storybook:playwright
### Running the workflow locally with `act`
[`act`](https://github.com/nektos/act) replays `.github/workflows/*.yml` on
your own machine using Docker, so you can reproduce a CI run (or a fix for
one) without pushing a branch.
[`act`](https://github.com/nektos/act) replays `.github/workflows/*.yml` on your
own machine using Docker, so you can reproduce a CI run (or a fix for one)
without pushing a branch.
**Install** (macOS):
@@ -401,34 +407,31 @@ act push \
--container-architecture linux/amd64
```
- `-j containerized-tests` runs only that job (there's currently just the
one).
- `-P ubuntu-latest=catthehacker/ubuntu:act-latest` pins the runner image
act uses to impersonate `ubuntu-latest`. Without it, act's first run
prompts interactively to choose a default image size, which hangs/fails
under a non-interactive shell.
- `-j containerized-tests` runs only that job (there's currently just the one).
- `-P ubuntu-latest=catthehacker/ubuntu:act-latest` pins the runner image act
uses to impersonate `ubuntu-latest`. Without it, act's first run prompts
interactively to choose a default image size, which hangs/fails under a
non-interactive shell.
- `--container-architecture linux/amd64` is required on Apple Silicon: the
`sqlserver` service's image is amd64-only, and act needs to know to
emulate that platform for the whole job container, not just that one
service.
`sqlserver` service's image is amd64-only, and act needs to know to emulate
that platform for the whole job container, not just that one service.
- No secrets or `.actrc` are needed: `generate-env.sh` creates its own
`.env.test` with freshly randomized values, same as in real CI.
**Known act-only limitation**: the "Upload test results" step
(`actions/upload-artifact@v4`) fails locally with `Unable to get the
ACTIONS_RUNTIME_TOKEN env variable`. act doesn't provide a real Actions
artifact backend, so this step, and only this step, is expected to fail
under act even when everything else passes. It works normally on
(`actions/upload-artifact@v4`) fails locally with
`Unable to get the ACTIONS_RUNTIME_TOKEN env variable`. act doesn't provide a
real Actions artifact backend, so this step, and only this step, is expected to
fail under act even when everything else passes. It works normally on
GitHub-hosted runners.
**Stale local state**: `docker-compose.test.yaml` uses fixed container
names and named volumes (`sqlserverdata-test`, `seaweedfsdata-test`) scoped
to the `web` compose project. If a previous local run (via `act` or a
manual `docker compose` invocation) didn't get torn down, its SQL Server
volume can persist with an old `SA_PASSWORD` baked in, while a fresh
`.env.test` generates a new one each run, causing `Login failed for user
'sa'` errors that look like a test bug but are really a leftover volume.
Clear it before re-running:
**Stale local state**: `docker-compose.test.yaml` uses fixed container names and
named volumes (`sqlserverdata-test`, `seaweedfsdata-test`) scoped to the `web`
compose project. If a previous local run (via `act` or a manual `docker compose`
invocation) didn't get torn down, its SQL Server volume can persist with an old
`SA_PASSWORD` baked in, while a fresh `.env.test` generates a new one each run,
causing `Login failed for user 'sa'` errors that look like a test bug but are
really a leftover volume. Clear it before re-running:
```bash
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

View File

@@ -1,4 +1,12 @@
# Token validation architecture
---
title: JWT token validation architecture
last-updated: 2026-08-31
tags:
- jwt
- authentication
- security
- tokens
---
## Overview
@@ -64,10 +72,9 @@ Token generation and validation live on the same slice-internal service.
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()`
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.
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
@@ -77,14 +84,16 @@ directly.
1. Receives confirmation token from user via `ConfirmUserCommand`
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token and looks it up via `UserManager<ApplicationUser>`
4. Marks the account confirmed via `IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`,
then persists it via `UserManager<ApplicationUser>.UpdateAsync()`
3. Extracts user ID from validated token and looks it up via
`UserManager<ApplicationUser>`
4. Marks the account confirmed via
`IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`, then persists it
via `UserManager<ApplicationUser>.UpdateAsync()`
5. Returns confirmation result
`UserManager<ApplicationUser>` and `IUserEmailStore<ApplicationUser>` are
ASP.NET Core Identity's abstractions, not EF Core's default Identity store:
the app backs them with its own `DapperUserStore` (see
ASP.NET Core Identity's abstractions, not EF Core's default Identity store: the
app backs them with its own `DapperUserStore` (see
[Database](database.md#database-error-handling)), which persists against the
existing `UserAccount`/`UserCredential`/`UserVerification` tables via Dapper,
consistent with the rest of the app's repository pattern.