Compare commits

29 Commits

Author SHA1 Message Date
Aaron Po
5028a43f4b Cover the split-screen auth shell in Storybook 2026-10-13 01:46:05 -04:00
Aaron Po
fffbe68dc2 List the action's rejected fields above the register form 2026-10-13 01:46:05 -04:00
Aaron Po
e6240bc889 Move focus into each new register step 2026-10-13 01:46:05 -04:00
Aaron Po
4a5abd4639 Build the register form as three steps 2026-10-13 01:46:05 -04:00
Aaron Po
d02f31b55c Declare the register steps and the schema slice each one collects 2026-10-13 01:46:05 -04:00
Aaron Po
e961ce4042 Move the login page onto the split-screen shell 2026-10-13 01:46:05 -04:00
Aaron Po
dfb67bcdfa Add the split-screen auth shell and its card components 2026-10-13 01:46:05 -04:00
Aaron Po
0ecc9a830e Hide the navbar auth actions on the login and register routes 2026-10-13 01:46:05 -04:00
Aaron Po
5ac55b8be0 Let route content fill the viewport under the navbar 2026-10-13 01:46:05 -04:00
Aaron Po
19791df6a5 Raise form inputs to a 44px touch target with 16px text 2026-10-13 01:46:05 -04:00
Aaron Po
0780349a79 Move the bar photo into shared assets 2026-10-13 01:46:05 -04:00
Aaron Po
c0cb013f54 Show a user's avatar on the public profile (#333) 2026-10-13 01:23:29 -04:00
Aaron Po
dfe248ea5f Create pull_request_template.md 2026-10-11 21:40:41 -04:00
Aaron Po
ff775e3b8a Raise frontend contrast, unify the page frame, and add browser identity (#328) 2026-10-08 00:17:45 -04:00
Aaron Po
0e51630439 Make the production compose stack deployable and disclose the AI-generated catalogue (#329) 2026-10-08 00:17:45 -04:00
Aaron Po
bd0e9e4a20 Create landing page (#321) 2026-10-03 18:46:32 -04:00
Aaron Po
c5972b9f04 Features/public biography endpoint (#316) 2026-09-26 19:51:10 -04:00
Aaron Po
e1b6cba1d4 Upgrade MediatR to 12.5.0 and forward cancellation tokens through pipeline behaviors (#320) 2026-09-26 19:50:44 -04:00
Aaron Po
0ff42c3c70 Add authenticated user profile page with locked-down account endpoint (#315) 2026-09-23 00:58:54 -04:00
Aaron Po
a6a9046457 Fix CA1873 warning and consolidate test fixtures (#313) 2026-09-12 22:14:55 -04:00
Aaron Po
40ae75808c Add ValidationBehavior test coverage (#311) 2026-09-12 21:08:24 -04:00
Aaron Po
fbede956e9 Fix seed pipeline crash and make seeding failures fail CI (#309) 2026-09-12 17:11:02 -04:00
Aaron Po
3793fca65e Features/exceptions pipeline integration (#308) 2026-09-12 16:24:14 -04:00
Aaron Po
78d0eaa739 Move Infrastructure.Configuration into Configuration (#291) 2026-09-12 15:59:37 -04:00
Aaron Po
180ed41e68 Move Infrastructure.FileUpload into Features.PhotoUpload (#290) 2026-09-12 00:22:19 -04:00
Aaron Po
ce2c90070e Update web docs for Infrastructure.Email move (#289) 2026-09-11 23:59:47 -04:00
Aaron Po
de63e9f216 Move Infrastructure.Email into Features.Emails (#288) 2026-09-11 23:46:04 -04:00
Aaron Po
7187631ebc Move Infrastructure.Jwt into Features.Users (#287) 2026-09-11 23:14:30 -04:00
Aaron Po
5fa78fbdf6 Move Infrastructure.PasswordHashing into Features.Users (#286) 2026-09-11 22:21:22 -04:00
186 changed files with 5377 additions and 959 deletions

101
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,101 @@
## Summary
<!--
State WHAT is changing and WHY, in two to five sentences.
A reader should understand the change without opening the diff.
Describe the problem first, then the outcome.
-->
## Type of change
<!-- Tick the one option that best describes this PR. -->
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor (no change in behaviour)
- [ ] Performance improvement
- [ ] Documentation
- [ ] Tests only
- [ ] Build, CI or dependency update
- [ ] Other (please specify):
## Related issues
<!--
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
Fixes #123
Fixes #124, fixes #125
Fixes owner/other-repo#456
Closing keywords only take effect when the PR targets the repository's default branch.
Use "Related to #123" if the PR should not close the issue.
If there is no issue, write "N/A" and explain the motivation in the Summary.
-->
## Approach and context
<!--
Explain how the change works and why this approach was chosen.
Include, where relevant:
- The decisions you made that are not obvious from the code.
- Alternatives you considered and why you rejected them.
- Known limitations or shortcomings, and any planned follow-up work.
- Background such as benchmark results or design documents.
Summarise the key points of any external link. Links may be inaccessible to future readers.
-->
## How this was tested
<!--
Describe what you ran and what you observed. Be specific, and state only what you actually did.
For example: the automated tests added or changed, manual steps and their outcome,
environments or platforms covered, and anything you were unable to test.
For a bug fix, note the test that fails without the fix.
-->
## Compatibility and risk
- [ ] This change is backwards compatible.
- [ ] This change is **breaking** (complete the details below).
- [ ] This change deprecates existing behaviour (complete the details below).
<!--
If breaking or deprecating, describe: what changes, who is affected,
and the migration path or recommended alternative.
Also note any deployment, configuration, data migration or rollback considerations.
-->
## User-facing change
<!--
Does this PR change behaviour that users, operators or API consumers will notice?
If not, write "None".
If so, write one or two sentences suitable for release notes, in plain language and from the
user's point of view. If users must take action when upgrading, state clearly that action is required.
-->
```release-note
```
## Notes for reviewers
<!--
Help reviewers focus. For example:
- Where to start, or a suggested order in which to read the files.
- Areas where you would particularly value scrutiny, or where you are uncertain.
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
or a code generator), so that reviewers can reproduce the steps.
- Screenshots or recordings for visual changes (include before and after where useful).
-->
## Checklist
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
- [ ] I have added or updated tests that cover this change.
- [ ] The build succeeds and the tests pass locally.
- [ ] The code follows the existing patterns and style of this repository.
- [ ] I have updated documentation, changelogs or API references where required.
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.

View File

@@ -33,7 +33,7 @@ jobs:
if: always() if: always()
run: | run: |
status=0 status=0
for name in test-env-api-specs test-env-unit-tests test-env-frontend-tests; do for name in test-env-database-migrations test-env-database-seed test-env-api-specs test-env-unit-tests test-env-frontend-tests; do
code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)" code="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo 1)"
echo "$name exited with $code" echo "$name exited with $code"
if [ "$code" != "0" ]; then if [ "$code" != "0" ]; then

View File

@@ -89,10 +89,8 @@ flowchart TB
subgraph INFRA["Infrastructure Layer"] subgraph INFRA["Infrastructure Layer"]
direction LR direction LR
I1["Database.Connection"] I1["Database.Connection"]
I2["Infrastructure.Jwt"]
I3["Infrastructure.PasswordHashing"] I3["Infrastructure.PasswordHashing"]
I4["Infrastructure.Email /<br/>Email.Templates"] I4["Email.Templates"]
I5["Infrastructure.FileUpload"]
end end
DB[("Database (SQL Server)")] DB[("Database (SQL Server)")]
@@ -133,7 +131,6 @@ own
- Every `Features.*` project (for controller/MediatR discovery) - Every `Features.*` project (for controller/MediatR discovery)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- `Infrastructure.Jwt` (for the auth middleware)
**Rules**: **Rules**:
@@ -167,10 +164,8 @@ brewery photo upload command) rather than bound to an HTTP route directly.
- `Domain.Entities`, `Domain.Exceptions` - `Domain.Entities`, `Domain.Exceptions`
- `Database.Connection` (generic ADO.NET connection/command plumbing) plus - `Database.Connection` (generic ADO.NET connection/command plumbing) plus
whichever infrastructure project the slice needs (`Infrastructure.Jwt`/ whichever infrastructure project the slice needs (`Infrastructure.PasswordHashing`
`Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/ for Users, `Infrastructure.Email.Templates` for Emails)
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
- No other `Features.*` project, with two exceptions: `Features.Users` - No other `Features.*` project, with two exceptions: `Features.Users`
references `Features.PhotoUpload` directly for avatar uploads, and references `Features.PhotoUpload` directly for avatar uploads, and
@@ -181,7 +176,9 @@ brewery photo upload command) rather than bound to an HTTP route directly.
- All business logic for that feature lives in its command/query handlers - All business logic for that feature lives in its command/query handlers
- No direct controller-to-repository calls; everything flows through MediatR - No direct controller-to-repository calls; everything flows through MediatR
- Read endpoints return a dedicated `Dto`, never the domain entity directly - Read endpoints return a dedicated `Dto` rather than the domain entity
directly. The one exception is `UserController.GetAuthenticated`, which
returns the caller's own `UserAccount` entity
#### Shared projects (`Shared.Contracts`, `Shared.Application`) #### Shared projects (`Shared.Contracts`, `Shared.Application`)
@@ -192,7 +189,7 @@ needs it, or because duplicating it four times would be worse than sharing it
| Project | Purpose | | Project | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope every controller returns | | `Shared.Contracts` | `ResponseBody<T>`/`ResponseBody`, the API response envelope `AuthController`, `BreweryController`, and `CityController` return; `UserController`'s read endpoints return their DTOs unwrapped |
| `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes | | `Shared.Application` | `ValidationBehavior<TRequest,TResponse>`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
**Rules**: **Rules**:
@@ -210,11 +207,8 @@ whichever slices need them
| Project | Description | | Project | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. | | `Database.Connection` | Generic ADO.NET connection/command plumbing (`DefaultSqlConnectionFactory`, the abstract `DapperRepository` base class), not domain-specific; each slice's own `Repository/` folder builds on this. |
| `Infrastructure.Jwt` | JWT token generation and validation. |
| `Infrastructure.PasswordHashing` | Argon2id password hashing. | | `Infrastructure.PasswordHashing` | Argon2id password hashing. |
| `Infrastructure.Email` | Email sending capabilities (SMTP/MailKit). |
| `Infrastructure.Email.Templates` | Email template rendering (Razor components). | | `Infrastructure.Email.Templates` | Email template rendering (Razor components). |
| `Infrastructure.FileUpload` | `IFileStorageProvider` abstraction over S3-compatible object storage, implemented by `S3FileStorageProvider` (targets the SeaweedFS container in dev; any S3-compatible endpoint in other environments) using the AWS SDK's `AmazonS3Client` with `ForcePathStyle = true`. |
**Dependencies**: **Dependencies**:
@@ -272,7 +266,8 @@ everything needed to understand or change one capability lives in one project
- Controllers generally bind directly to the Command/Query as the request body; - Controllers generally bind directly to the Command/Query as the request body;
there is no separate request DTO + mapping step for writes there is no separate request DTO + mapping step for writes
- The exception to this is for authenticated routes, when the current user id - 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. needs to be extracted from the authentication token to be used in a command
or query.
- A single shared `ValidationBehavior<TRequest,TResponse>` - A single shared `ValidationBehavior<TRequest,TResponse>`
(`Shared.Application/Behaviors/`) runs FluentValidation validators in the (`Shared.Application/Behaviors/`) runs FluentValidation validators in the
MediatR pipeline before any handler executes MediatR pipeline before any handler executes
@@ -387,6 +382,7 @@ web/frontend/
│ │ - catalog beers, beer styles │ │ - catalog beers, beer styles
│ │ - home landing page │ │ - home landing page
│ │ - theme theme switcher/guide │ │ - theme theme switcher/guide
│ │ - users public user profile page (/users/:id)
│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery │ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery
│ ├── routes.ts Route table (React Router 8 config-based routing) │ ├── routes.ts Route table (React Router 8 config-based routing)
│ ├── root.tsx App shell and global providers │ ├── root.tsx App shell and global providers
@@ -403,6 +399,8 @@ web/frontend/
`/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby" `/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby"
search search
- Render the auth demo, account dashboard, and theme guide routes - Render the auth demo, account dashboard, and theme guide routes
- Render the public user profile route (`/users/:id`), including a user's
following list and (via brewery comments) links back to commenter profiles
- Manage cookie-backed website session state - Manage cookie-backed website session state
- Call the .NET API for login, registration, token refresh, confirmation, and - Call the .NET API for login, registration, token refresh, confirmation, and
brewery/location data brewery/location data
@@ -568,10 +566,11 @@ are not seeded either.
**Environments**: **Environments**:
| Environment | Compose file | | Environment | Compose file |
| ----------- | -------------------------- | | --------------- | ------------------------------- |
| Development | `docker-compose.dev.yaml` | | Development | `docker-compose.dev.yaml` |
| Testing | `docker-compose.test.yaml` | | Testing | `docker-compose.test.yaml` |
| Production | `docker-compose.prod.yaml` | | Production | `docker-compose.prod.yaml` |
| Production seed | `docker-compose.prod.seed.yaml` |
For details, see [Docker Guide](docker.md). For details, see [Docker Guide](docker.md).

View File

@@ -76,14 +76,14 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique | | `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 | | `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 | | `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` | | `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` | | `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing | | `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 | | `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`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` | | `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; 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 | | `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 | | `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` | | `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; not yet seeded by `Database.Seed` |
### Tables defined in the schema but not yet wired to a feature slice ### Tables defined in the schema but not yet wired to a feature slice

View File

@@ -41,7 +41,7 @@ no data flowing over them.
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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). | | **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, in parallel once seeding completes; results are aggregated. | | **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, in parallel once seeding completes; results are aggregated. |
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. | | **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. |
| **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. | | **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. | | **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. | | `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. |

View File

@@ -1,11 +1,13 @@
sequenceDiagram sequenceDiagram
box Production Environment (docker-compose.prod.yaml) box Production Environment (docker-compose.prod.yaml)
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
participant ProdMig as Migrations (run-once) participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
end end
ProdMig-->>ProdDB: depends_on ProdAPI-->>ProdS3: depends_on: seaweedfs healthy
ProdMig->>ProdDB: Run migrations ProdWeb-->>ProdAPI: depends_on: api.core healthy
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
ProdAPI->>ProdDB: Connect and serve ProdAPI->>ProdDB: Connect and serve
ProdAPI->>ProdS3: Store and read photos
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack

View File

@@ -97,9 +97,9 @@ see the notes for what else each of those foundation projects pulls in.
- **Omitted from the diagram**: - **Omitted from the diagram**:
- `Domain.Exceptions` (referenced by nearly every project directly, including - `Domain.Exceptions` (referenced by nearly every project directly, including
all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) all of `Shared.Contracts` and each feature slice)
- `Infrastructure.Configuration` (the shared options-binding project - `Configuration` (the shared options-binding project
referenced by `Database.Connection`, `Infrastructure.Email`, referenced by `Database.Connection`, `Features.PhotoUpload`,
`Infrastructure.FileUpload`, and `Features.Emails` directly) and `Features.Emails` directly)
- both are plumbing-level dependencies of nearly everything above, and drawing - both are plumbing-level dependencies of nearly everything above, and drawing
them would just add a second copy of the same fan-in them would just add a second copy of the same fan-in

View File

@@ -23,10 +23,7 @@ end
subgraph INFRA["Slice-specific Infrastructure"] subgraph INFRA["Slice-specific Infrastructure"]
direction LR direction LR
JWT["Infrastructure.Jwt"]
PwdHash["Infrastructure.PasswordHashing"] PwdHash["Infrastructure.PasswordHashing"]
FileUpload["Infrastructure.FileUpload"]
Email["Infrastructure.Email"]
EmailTemplates["Infrastructure.Email.Templates"] EmailTemplates["Infrastructure.Email.Templates"]
end end
@@ -35,7 +32,7 @@ EmailsSlice == project ref, handles UserRegisteredNotification & ConfirmationEma
BrewerySlice --> FOUNDATION BrewerySlice --> FOUNDATION
LocationsSlice --> FOUNDATION LocationsSlice --> FOUNDATION
BeersSlice --> FOUNDATION BeersSlice --> FOUNDATION
PhotoSlice --> FOUNDATION & FileUpload PhotoSlice --> FOUNDATION
UsersSlice --> FOUNDATION & JWT & PwdHash UsersSlice --> FOUNDATION & PwdHash
EmailsSlice --> Email & EmailTemplates EmailsSlice --> EmailTemplates
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")] DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]

View File

@@ -98,7 +98,7 @@ 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
unit.tests # All Features.*.Tests unit test projects unit.tests # All Features.*.Tests and Shared.*.Tests unit test projects
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image) frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
``` ```
@@ -134,27 +134,136 @@ containers, which would end the run prematurely. See
- Production logging levels - Production logging levels
- No database clearing - No database clearing
- Optimized build configurations - Optimized build configurations
- Health checks enabled - Health checks on every long-running service
- Restart policies (unless-stopped) - Restart policies that survive a reboot (see
[Restart tolerance](#restart-tolerance))
- Security hardening - Security hardening
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are - Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
reachable only from other containers on `prodnet` proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
reachable only from other containers on `prodnet` (named `biergarten-prodnet`
so the seed job can join it from its own Compose project)
- Seeding is a separate, explicitly run compose file
(`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing
catalogue as it is. See [Production data](#production-data) below.
**Services**: **Services**:
```yaml ```yaml
sqlserver # Production SQL Server (internal only) sqlserver # Production SQL Server (internal only)
database.migrations # Schema updates only
api.core # Production API (internal only, no published ports) api.core # Production API (internal only, no published ports)
frontend # Production website (port 3000, the only public binding) seaweedfs # S3-compatible object storage (internal only)
frontend # Production website (127.0.0.1:5656, the only host binding)
``` ```
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
restart touches neither the schema nor the catalogue. Run them explicitly at
deploy time: see [Migrating production](#migrating-production) and
[Seeding production](#seeding-production-docker-composeprodseedyaml).
**Deploy Production**: **Deploy Production**:
```bash ```bash
# One time, before the first `up`: the data volumes are external, so Compose
# expects them to exist already.
docker volume create biergartendata-prod
docker volume create seaweedfsdata-prod
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
``` ```
Docker seeds each volume with the ownership the image expects and labels it for
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
enforcing distributions such as RHEL, AlmaLinux and Fedora.
#### Migrating production
`database.migrations` is a run-once job rather than a service in the prod stack,
so the schema is migrated when an operator asks for it instead of on every boot.
Build the image and run it against the network the prod stack creates:
```bash
docker build -t database.migrations \
-f web/backend/Database/Database.Migrations/Dockerfile \
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
web/backend
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
```
DbUp records every script it has applied, so a re-run applies only what is new.
Run this with `sqlserver` already up and healthy, and before the seed job.
#### Restart tolerance
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
`restart: unless-stopped`, so the stack returns on its own after a container
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
boot (`systemctl enable docker`). The migration and seed jobs stay out of the
stack, so a reboot replays neither.
The daemon replays restart policies without replaying the `depends_on` graph, so
after a reboot the containers come back in arbitrary order and each one tolerates
a dependency that arrives late. `api.core` opens its database and S3 connections
per request rather than at startup, and `frontend` answers with its own error
states while `api.core` is still coming up.
Health checks report when the stack has converged:
| Service | Probe |
| --- | --- |
| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries |
| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client |
| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up |
| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API |
`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to
finish on shutdown instead of being recovered on the next start.
Deploy and wait for the stack to report healthy, or check convergence after a
reboot:
```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
```
#### Seeding production (`docker-compose.prod.seed.yaml`)
`database.seed` lives in its own compose file, run on demand once the prod stack
is up, `sqlserver` is healthy, and the schema is migrated:
```bash
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
run --rm --build database.seed
```
The job runs as the `biergarten-prod-seed` Compose project and attaches to the
external `biergarten-prodnet` network, which the main prod stack creates, so it
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
exits when seeding finishes; `--rm` removes the container. Bring the prod stack
up first, because the seed project has no visibility into the other project's
health checks, so a run started before SQL Server is accepting connections fails.
#### Production data
A production deployment seeded this way serves the AI-generated fixture data
produced by the pipeline. Per
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
anyone interacting with an application seeded from it has to be told upfront that
the content is AI-generated.
The website meets that obligation with `AiDisclosureDialog`
(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which
the root route renders on every full page load, whichever route the visitor lands
on. It is part of the server-rendered HTML rather than mounted after hydration, so
the disclosure is on screen with the first paint. Acknowledging it hides the
dialog for the remainder of that page load; a new load shows it again.
Keep the seed job and the dialog together: any deployment that runs
`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
ethics documentation.
### 4. Database only (`docker-compose.db.yaml`) ### 4. Database only (`docker-compose.db.yaml`)
**Purpose**: Run just the database for local backend development outside Docker **Purpose**: Run just the database for local backend development outside Docker
@@ -215,6 +324,12 @@ graph TD
In development, `api.core` also depends on `seaweedfs` In development, `api.core` also depends on `seaweedfs`
(`condition: service_started`, not a health check) alongside `database.seed`. (`condition: service_started`, not a health check) alongside `database.seed`.
Production has a shorter chain, because the migration and seed jobs run outside
the stack: `api.core` waits for `seaweedfs` to pass its health check, and
`frontend` waits for `api.core` to report healthy. This ordering applies to
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens
on a host reboot.
**Health Check Example** (SQL Server): **Health Check Example** (SQL Server):
```yaml ```yaml
@@ -255,8 +370,10 @@ api.core:
**Production**: **Production**:
- `sqlserverdata-prod` - Production database files - `biergartendata-prod` - Database files, external; survives `down -v`
- `nuget-cache-prod` - Production NuGet cache - `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
See [Production data volumes](#production-data-volumes).
### Mounted volumes ### Mounted volumes
@@ -269,6 +386,41 @@ volumes:
Test results are written to host filesystem for CI/CD integration. Test results are written to host filesystem for CI/CD integration.
### Production data volumes
`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`.
Compose removes only the volumes it owns, so `docker compose down -v` tears the
stack down and leaves the database and the uploaded photos in place. Deleting
them takes an explicit `docker volume rm`.
The trade-off is that Compose creates neither one, and `up` fails with a clear
error until both exist, hence the `docker volume create` step in
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
carry their literal name rather than a `<project>_` prefix, so `docker volume ls`
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the
`web_`-prefixed volumes of the dev and test stacks.
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
`/var/lib/docker`.
Back up the database through SQL Server rather than by copying its files, since a
file copy taken while the engine is running can be torn:
```bash
docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P '<db-password>' -C \
-Q "BACKUP DATABASE [<db-name>] TO DISK = '/var/opt/mssql/data/backup.bak'"
docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak .
```
Object storage can be archived directly, with the stack stopped:
```bash
docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \
alpine tar czf /backup/seaweedfs.tar.gz -C /data .
```
## Networks ## Networks
Each environment uses isolated bridge networks: Each environment uses isolated bridge networks:

View File

@@ -233,7 +233,7 @@ file (`development` in dev, `production` in prod).
### SMTP configuration (backend) ### SMTP configuration (backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and Read by `Features.Emails/Services/SmtpEmailProvider.cs` for sending confirmation and
account emails. account emails.
```bash ```bash
@@ -246,7 +246,7 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app # Required, no default
SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten" SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten"
``` ```
- **Implementation**: `Infrastructure.Email/SmtpEmailProvider.cs` throws on - **Implementation**: `Features.Emails/Services/SmtpEmailProvider.cs` throws on
startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing startup if `SMTP_HOST` or `SMTP_FROM_EMAIL` is missing
- **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web - **Local dev**: point at the `mailpit` Docker service (SMTP on port 1025, web
UI on http://localhost:8025) UI on http://localhost:8025)
@@ -255,22 +255,30 @@ SMTP_FROM_NAME=The Biergarten App # Optional, defaults to "The Biergarten
### File storage configuration (backend) ### File storage configuration (backend)
Read by `Infrastructure.FileUpload/S3FileStorageProvider.cs` for uploading and Read by `Features.PhotoUpload/Services/S3FileStorageProvider.cs` for uploading
retrieving photos via an S3-compatible object store. and retrieving photos via an S3-compatible object store.
```bash ```bash
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
SEAWEEDFS_BUCKET=my-bucket # Required, no default SEAWEEDFS_BUCKET=my-bucket # Required, no default
``` ```
- **Implementation**: `S3FileStorageProvider` throws on construction if any of - **Implementation**: `S3FileStorageProvider` throws on construction if any of
these four values is missing the four required values is missing
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333) - **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted - **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
SeaweedFS requires and AWS S3 also accepts SeaweedFS requires and AWS S3 also accepts
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
used by `GetPresignedUrl` and reaching the API response as
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
this has to be the address the browser uses (`http://localhost:8333` with the
dev compose file, which publishes the S3 API port), separate from the
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
same value wherever one address serves both
## Docker-specific variables ## Docker-specific variables
@@ -317,6 +325,7 @@ from the template in place; edit them manually if you go that route.
- `web/docker-compose.dev.yaml` → `web/.env.dev` - `web/docker-compose.dev.yaml` → `web/.env.dev`
- `web/docker-compose.test.yaml` → `web/.env.test` - `web/docker-compose.test.yaml` → `web/.env.test`
- `web/docker-compose.prod.yaml` → `web/.env.prod` - `web/docker-compose.prod.yaml` → `web/.env.prod`
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
## Variable reference table ## Variable reference table
@@ -340,6 +349,7 @@ from the template in place; edit them manually if you go that route.
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address | | `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` | | `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL | | `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key | | `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key | | `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name | | `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
@@ -414,6 +424,7 @@ SMTP_FROM_NAME=The Biergarten App
# SeaweedFS (S3-compatible storage) # SeaweedFS (S3-compatible storage)
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl> SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl> SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -76,10 +76,11 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
You can run individual test projects locally without Docker: You can run individual test projects locally without Docker:
### Feature slice unit tests ### Feature slice and shared unit tests
Each feature slice has its own test project, covering its command/query Each feature slice has its own test project, covering its command/query
handlers: handlers. Shared cross-cutting code (e.g. MediatR pipeline behaviors) has
its own test project under `Shared/`:
```bash ```bash
cd web/backend cd web/backend
@@ -87,9 +88,10 @@ dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
dotnet test Shared/Shared.Application.Tests/Shared.Application.Tests.csproj
# Or run all of them at once via the solution: # Or run all of them at once via the solution:
for proj in Features/*.Tests; do dotnet test "$proj"; done for proj in Features/*.Tests Shared/*.Tests; do dotnet test "$proj"; done
``` ```
**Requirements**: **Requirements**:
@@ -247,13 +249,16 @@ API.Specs/
│ ├── ResendConfirmation.feature # Resend-confirmation scenarios │ ├── ResendConfirmation.feature # Resend-confirmation scenarios
│ ├── TokenRefresh.feature # Refresh token scenarios │ ├── TokenRefresh.feature # Refresh token scenarios
│ ├── AccessTokenValidation.feature # Protected endpoint access scenarios │ ├── AccessTokenValidation.feature # Protected endpoint access scenarios
│ └── NotFound.feature # 404 handling │ ├── NotFound.feature # 404 handling
│ └── PublicUserProfile.feature # Public profile & authenticated account scenarios
├── Steps/ ├── Steps/
│ ├── AuthSteps.cs # Step definitions for the Auth features │ ├── AuthSteps.cs # Step definitions for the Auth features
│ ├── UserSteps.cs # Step definitions for the public profile & user-account features
│ └── ApiGeneralSteps.cs # Shared/general step definitions │ └── ApiGeneralSteps.cs # Shared/general step definitions
├── Mocks/ ├── Mocks/
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher │ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
│ └── MockEmailProvider.cs # Substitutes Infrastructure.Email's IEmailProvider │ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
└── TestApiFactory.cs # Test server setup (swaps in the mocks above) └── TestApiFactory.cs # Test server setup (swaps in the mocks above)
``` ```
@@ -278,17 +283,25 @@ Scenario: Successful user registration
Features.Users.Tests/ Features.Users.Tests/
├── Commands/ ├── Commands/
│ ├── RegisterUserHandlerTests.cs │ ├── RegisterUserHandlerTests.cs
│ ├── RegisterUserValidatorTests.cs
│ ├── ConfirmUserHandlerTests.cs │ ├── ConfirmUserHandlerTests.cs
│ ├── ResendConfirmationEmailHandlerTests.cs │ ├── ResendConfirmationEmailHandlerTests.cs
│ ├── RefreshTokenHandlerTests.cs │ ├── RefreshTokenHandlerTests.cs
│ ├── RefreshTokenValidatorTests.cs
│ ├── LoginValidatorTests.cs # tests LoginCommand's validator
│ ├── UpdateUsernameHandlerTests.cs │ ├── UpdateUsernameHandlerTests.cs
│ ├── UpdateUsernameValidatorTests.cs
│ ├── UpdateEmailHandlerTests.cs │ ├── UpdateEmailHandlerTests.cs
│ ├── UpdateEmailValidatorTests.cs
│ ├── UpdatePasswordHandlerTests.cs │ ├── UpdatePasswordHandlerTests.cs
│ ├── UpdatePasswordValidatorTests.cs
│ ├── UpdateProfileHandlerTests.cs │ ├── UpdateProfileHandlerTests.cs
│ ├── UpdateProfileValidatorTests.cs
│ └── DeleteAccountHandlerTests.cs │ └── DeleteAccountHandlerTests.cs
├── Queries/ ├── Queries/
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name │ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
│ ├── GetAllUsersHandlerTests.cs │ ├── GetAllUsersHandlerTests.cs
│ ├── GetPublicUserProfileByIdHandlerTests.cs
│ └── GetUserByIdHandlerTests.cs │ └── GetUserByIdHandlerTests.cs
├── Services/ ├── Services/
│ ├── TokenServiceRefreshTests.cs │ ├── TokenServiceRefreshTests.cs
@@ -296,10 +309,13 @@ Features.Users.Tests/
└── TestSupport/ # shared test fixtures/helpers └── TestSupport/ # shared test fixtures/helpers
``` ```
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.Breweries.Tests` and `Features.PhotoUpload.Tests` follow the same
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` shape: a `Commands/`/`Queries/` folder with one test file per handler, plus a
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds a `*ValidatorTests.cs` file beside each handler whose command or query has a
validator test file alongside its handler test). FluentValidation validator (`Features.Breweries.Tests` has one of these in
`Queries/` as well, for the nearby-search query). `Features.Emails.Tests`
differs: its handlers respond to MediatR notifications, so its test files live
under `Notifications/`.
## Writing tests ## Writing tests

View File

@@ -22,7 +22,7 @@ The Biergarten API validates JSON Web Tokens (JWTs) across three token types:
### Infrastructure layer ### Infrastructure layer
#### [ITokenInfrastructure](../../web/backend/Infrastructure/Infrastructure.Jwt/ITokenInfrastructure.cs) #### [ITokenInfrastructure](../../web/backend/Features/Features.Users/Services/ITokenInfrastructure.cs)
Low-level JWT operations. Low-level JWT operations.
@@ -32,7 +32,7 @@ Low-level JWT operations.
- `ValidateJwtAsync()` - Validates token signature, expiration, and format - `ValidateJwtAsync()` - Validates token signature, expiration, and format
**Implementation:** **Implementation:**
[JwtInfrastructure.cs](../../web/backend/Infrastructure/Infrastructure.Jwt/JwtInfrastructure.cs) [JwtInfrastructure.cs](../../web/backend/Features/Features.Users/Services/JwtInfrastructure.cs)
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler - Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
- Algorithm: HS256 (HMAC-SHA256) - Algorithm: HS256 (HMAC-SHA256)
@@ -76,12 +76,11 @@ directly, bypassing the `Features.Users` slice entirely.
- Extracts and validates claims (Sub, UniqueName) - Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure - Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()` `TokenService` and the lower-level `ITokenInfrastructure` (JWT
extension method, except for the lower-level `ITokenInfrastructure` (JWT signing/verification) it depends on are both registered by `Features.Users`'
signing/verification) it depends on, which is registered by the host own `AddFeaturesUsers()` extension method. `JwtAuthenticationHandler`
(`API.Core/ServiceCollectionExtensions.cs`, via `AddCoreInfrastructure()`, (host-level auth middleware) also depends on `ITokenInfrastructure` directly,
called from `Program.cs`) since `JwtAuthenticationHandler` (host-level auth resolving it from the same DI container.
middleware) also depends on it directly.
### Integration points ### Integration points

View File

@@ -68,8 +68,12 @@ WEBSITE_BASE_URL=http://localhost:3000
# ====================== # ======================
# SeaweedFS (S3) Configuration # SeaweedFS (S3) Configuration
# ====================== # ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage # Credentials and default bucket for the local SeaweedFS S3-compatible storage.
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
SEAWEEDFS_ACCESS_KEY_ID=admin SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket SEAWEEDFS_BUCKET=my-bucket

View File

@@ -15,16 +15,14 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference 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 Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.5.0" />
</ItemGroup> </ItemGroup>
<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="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />

View File

@@ -1,7 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using Infrastructure.Configuration; using Features.Users.Services;
using Infrastructure.Jwt; using Configuration;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;

View File

@@ -33,12 +33,9 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Configuration/Configuration.csproj", "Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -12,8 +12,6 @@ using Features.Users.Controllers;
using Features.Users.DependencyInjection; using Features.Users.DependencyInjection;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.FileUpload;
using Infrastructure.Jwt;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors; using Shared.Application.Behaviors;
@@ -48,6 +46,7 @@ internal static class ServiceCollectionExtensions
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly); cfg.RegisterServicesFromAssembly(typeof(CityController).Assembly);
cfg.AddOpenBehavior(typeof(UnhandledExceptionLoggingBehavior<,>));
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>)); cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
}); });
@@ -104,11 +103,7 @@ internal static class ServiceCollectionExtensions
.AddFeaturesLocations(); .AddFeaturesLocations();
public IServiceCollection AddCoreInfrastructure() => public IServiceCollection AddCoreInfrastructure() =>
services services.AddScoped<GlobalExceptionFilter>();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
.AddScoped<ITokenInfrastructure, JwtInfrastructure>()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
.AddScoped<GlobalExceptionFilter>();
public IServiceCollection AddJwtAuthentication() public IServiceCollection AddJwtAuthentication()
{ {

View File

@@ -38,8 +38,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\API.Core\API.Core.csproj" /> <ProjectReference Include="..\API.Core\API.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />

View File

@@ -23,12 +23,9 @@ COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Fea
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Configuration/Configuration.csproj", "Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj", "Infrastructure/Infrastructure.FileUpload/"]
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"] COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Application.Tests/Shared.Application.Tests.csproj", "Shared/Shared.Application.Tests/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"] COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
COPY . . COPY . .

View File

@@ -0,0 +1,130 @@
Feature: Public User Profile
As a client of the API
I want to read a user's public-facing profile
So that a public profile page can render without exposing private account fields
Background:
Given the API is running
Scenario: Public profile exposes only allowlisted fields
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should match the registered account
And the response JSON should have "biography" equal ""
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Public profile exposes the uploaded avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Public profile exposes the current avatar after it is replaced
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I have uploaded a new avatar, replacing the previous one
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
And the public profile response should not carry my previous avatar
Scenario: Fetching the avatar without authentication succeeds
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: A different user can read someone else's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should carry my current avatar
Scenario: Listing user accounts exposes each account's avatar
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my current avatar
Scenario: Listing user accounts exposes each account's biography
Given I have registered a new account
And I have a valid access token for my account
And I have set my biography to "Drinks lagers."
When I list user accounts
Then the response has HTTP status 200
And my listed account should carry my biography
Scenario: Public profile for a non-existent account returns not found
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by a non-existent ID
Then the response has HTTP status 404
Scenario: Fetching the public profile without authentication succeeds
Given I have registered a new account
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the response JSON should have "avatarUrl" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: A different user can read someone else's public profile
Given I have registered a new account
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Fetching my own account without authentication is rejected
When I retrieve my own account without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts without authentication is rejected
When I list user accounts without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts as an authenticated user excludes private fields
Given I have registered a new account
And I have a valid access token for my account
When I list user accounts
Then the response has HTTP status 200
And the response JSON should contain "avatarUrl"
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: The caller can fetch their own account
Given I have registered a new account
And I have a valid access token for my account
When I retrieve my own account
Then the response has HTTP status 200
And the public profile response should match the registered account
Scenario: The caller's own account omits the avatar graph
Given I have registered a new account
And I have a valid access token for my account
And I have uploaded an avatar
When I retrieve my own account
Then the response has HTTP status 200
And the response JSON should not contain "userAvatar"
Scenario: The account endpoint serves the caller named by the access token
Given I have registered a new account
And I am logged in as a different user
When I retrieve my own account
Then the response has HTTP status 200
And the account response should be for a different account than the registered one

View File

@@ -1,4 +1,4 @@
using Infrastructure.Email; using Features.Emails.Services;
namespace API.Specs.Mocks; namespace API.Specs.Mocks;

View File

@@ -0,0 +1,55 @@
using Features.ImageUploads.Services;
namespace API.Specs.Mocks;
/// <summary>
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
/// exercise photo-backed features without object storage.
/// </summary>
public class MockFileStorageProvider : IFileStorageProvider
{
private readonly List<string> _keys = [];
private readonly Dictionary<string, byte[]> _objects = new();
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
/// <summary>The key of the most recent upload.</summary>
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
public string LastUploadedKey =>
_keys.Count > 0
? _keys[^1]
: throw new InvalidOperationException("no file has been uploaded");
public Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
using MemoryStream buffer = new();
content.CopyTo(buffer);
if (!_objects.ContainsKey(key))
_keys.Add(key);
_objects[key] = buffer.ToArray();
return Task.CompletedTask;
}
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
_objects.Remove(key);
_keys.Remove(key);
return Task.CompletedTask;
}
/// <summary>
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
/// carrying the key in its path, so specs can assert the shape callers actually receive.
/// </summary>
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
}
}

View File

@@ -167,4 +167,86 @@ public class ApiGeneralSteps(ScenarioContext scenario)
actualValue actualValue
); );
} }
[Then("the response JSON should not contain {string}")]
public void ThenTheResponseJsonShouldNotContainString(string field)
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
AssertFieldAbsent(root, field);
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("payload", out JsonElement payloadElem))
AssertFieldAbsent(payloadElem, field);
}
[Then("the response JSON should contain {string}")]
public void ThenTheResponseJsonShouldContainString(string field)
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
!root.TryGetProperty(field, out _) &&
root.TryGetProperty("payload", out JsonElement payloadElem))
{
AssertFieldPresent(payloadElem, field);
return;
}
AssertFieldPresent(root, field);
}
/// <summary>
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
/// single-resource response.
/// </summary>
private static void AssertFieldAbsent(JsonElement element, string field)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
element
.TryGetProperty(field, out _)
.Should()
.BeFalse("Expected field '{0}' to be absent from the response", field);
break;
case JsonValueKind.Array:
foreach (JsonElement item in element.EnumerateArray())
AssertFieldAbsent(item, field);
break;
}
}
/// <summary>
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
/// </summary>
private static void AssertFieldPresent(JsonElement element, string field)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
element
.TryGetProperty(field, out _)
.Should()
.BeTrue("Expected field '{0}' to be present in the response", field);
break;
case JsonValueKind.Array:
element
.EnumerateArray()
.Should()
.NotBeEmpty(
"Expected at least one item to assert field '{0}' against",
field
);
foreach (JsonElement item in element.EnumerateArray())
AssertFieldPresent(item, field);
break;
}
}
} }

View File

@@ -1,8 +1,8 @@
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using Features.Users.Services;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Configuration; using Configuration;
using Infrastructure.Jwt;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Reqnroll; using Reqnroll;
@@ -347,6 +347,46 @@ public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root); Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
} }
[Given("I am logged in as a different user")]
public async Task GivenIAmLoggedInAsADifferentUser()
{
HttpClient client = GetClient();
string suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new
{
username = $"otheruser-{suffix}",
firstName = "Other",
lastName = "User",
email = $"otheruser-{suffix}@example.com",
dateOfBirth = "1990-01-01",
password = "Password1!",
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(
JsonSerializer.Serialize(registrationData),
Encoding.UTF8,
"application/json"
),
};
HttpResponseMessage response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement payload = doc.RootElement.GetProperty("payload");
string accessToken =
(
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
? tokenElem.GetString()
: null
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
Scenario["accessToken"] = accessToken;
}
[Given("I am logged in")] [Given("I am logged in")]
public async Task GivenIAmLoggedIn() public async Task GivenIAmLoggedIn()
{ {

View File

@@ -262,46 +262,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
await SendAsync(requestMessage); await SendAsync(requestMessage);
} }
[Given("I am logged in as a different user")]
public async Task GivenIAmLoggedInAsADifferentUser()
{
HttpClient client = GetClient();
string suffix = Guid.NewGuid().ToString("N")[..8];
var registrationData = new
{
username = $"otheruser-{suffix}",
firstName = "Other",
lastName = "User",
email = $"otheruser-{suffix}@example.com",
dateOfBirth = "1990-01-01",
password = "Password1!",
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/register")
{
Content = new StringContent(
JsonSerializer.Serialize(registrationData),
Encoding.UTF8,
"application/json"
),
};
HttpResponseMessage response = await client.SendAsync(requestMessage);
response.EnsureSuccessStatusCode();
string responseBody = await response.Content.ReadAsStringAsync();
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement payload = doc.RootElement.GetProperty("payload");
string accessToken =
(
payload.TryGetProperty("accessToken", out JsonElement tokenElem)
? tokenElem.GetString()
: null
) ?? throw new InvalidOperationException("accessToken missing from registration payload");
Scenario[AccessTokenKey] = accessToken;
}
[Then("retrieving the brewery by ID should now return HTTP status {int}")] [Then("retrieving the brewery by ID should now return HTTP status {int}")]
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode) public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
{ {

View File

@@ -0,0 +1,290 @@
using System.Text.Json;
using API.Specs.Mocks;
using Features.ImageUploads.Services;
using Features.Users.Commands.Profile.UploadAvatar;
using Features.Users.Repository;
using FluentAssertions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll;
namespace API.Specs.Steps;
[Binding]
public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
{
private const string AccessTokenKey = "accessToken";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
private const string CurrentAvatarKeyKey = "currentAvatarKey";
private const string PreviousAvatarKeyKey = "previousAvatarKey";
private const string BiographyKey = "biography";
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
private async Task SendAsync(HttpRequestMessage requestMessage)
{
HttpClient client = GetClient();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
{
HttpRequestMessage requestMessage = new(method, url);
if (Scenario.TryGetValue<string>(AccessTokenKey, out string? accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
return requestMessage;
}
private Guid GetRegisteredUserId()
{
return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
}
/// <summary>
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
/// 1 KiB minimum.
/// </summary>
private static IFormFile CreateAvatarFile()
{
byte[] content = new byte[2048];
PngSignature.CopyTo(content, 0);
MemoryStream stream = new(content);
return new FormFile(stream, 0, content.Length, "file", "avatar.png")
{
Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
};
}
/// <summary>
/// Uploads an avatar and records the object key the storage provider actually received, rather
/// than re-deriving the key template the handler uses.
/// </summary>
private async Task UploadAvatarAsync()
{
Guid userId = GetRegisteredUserId();
using IServiceScope scope = GetFactory().Services.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
Scenario[PreviousAvatarKeyKey] = existing;
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
}
private MockFileStorageProvider GetStorageProvider()
{
return (MockFileStorageProvider)
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
}
private string GetCurrentAvatarKey()
{
return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
}
private string GetPreviousAvatarKey()
{
return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null
? key
: throw new InvalidOperationException(
"only one avatar has been uploaded in this scenario"
);
}
/// <summary>
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
/// storage key would resolve against the page's own origin, so the shape is the contract here,
/// not just the value.
/// </summary>
private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey)
{
avatarUrl.Should().NotBeNullOrWhiteSpace();
Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri)
.Should()
.BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl);
uri!.Scheme.Should().BeOneOf("http", "https");
uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object");
return uri;
}
private string GetAvatarUrlFromResponse()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
return doc.RootElement.GetProperty("avatarUrl").GetString()!;
}
[Given("I have uploaded an avatar")]
public async Task GivenIHaveUploadedAnAvatar()
{
await UploadAvatarAsync();
}
[Given("I have uploaded a new avatar, replacing the previous one")]
public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
{
await UploadAvatarAsync();
}
/// <summary>
/// Writes the biography straight through the repository: registration creates the profile row
/// with an empty biography, and no endpoint edits it yet.
/// </summary>
[Given("I have set my biography to {string}")]
public async Task GivenIHaveSetMyBiographyTo(string biography)
{
using IServiceScope scope = GetFactory().Services.CreateScope();
IUserProfileRepository profiles =
scope.ServiceProvider.GetRequiredService<IUserProfileRepository>();
await profiles.UpdateBiographyAsync(
GetRegisteredUserId(),
biography,
CancellationToken.None
);
Scenario[BiographyKey] = biography;
}
[Then("the public profile response should carry my current avatar")]
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
}
[Then("the public profile response should not carry my previous avatar")]
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
{
Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey());
}
[Then("my listed account should carry my current avatar")]
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
{
AssertFetchableUrl(
GetMyListedAccount().GetProperty("avatarUrl").GetString(),
GetCurrentAvatarKey()
);
}
[Then("my listed account should carry my biography")]
public void ThenMyListedAccountShouldCarryMyBiography()
{
string expected =
Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null
? biography
: throw new InvalidOperationException("no biography was set in this scenario");
GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected);
}
/// <summary>Finds the registered account in a listing response.</summary>
private JsonElement GetMyListedAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
return doc
.RootElement.EnumerateArray()
.Should()
.ContainSingle(item =>
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
)
.Subject.Clone();
}
[When("I retrieve the public profile by ID")]
public async Task WhenIRetrieveThePublicProfileById()
{
await SendAsync(
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
}
[When("I retrieve the public profile by ID without authentication")]
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
{
await SendAsync(
new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
);
}
[When("I retrieve the public profile by a non-existent ID")]
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
{
await SendAsync(
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")
);
}
[When("I retrieve my own account without authentication")]
public async Task WhenIRetrieveMyOwnAccountWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user/authenticated"));
}
[When("I list user accounts without authentication")]
public async Task WhenIListUserAccountsWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user"));
}
[When("I list user accounts")]
public async Task WhenIListUserAccounts()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
}
[When("I retrieve my own account")]
public async Task WhenIRetrieveMyOwnAccount()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user/authenticated"));
}
[Then("the public profile response should match the registered account")]
public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? u)
? u
: throw new InvalidOperationException("registered username not found in scenario");
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
root.GetProperty("username").GetString().Should().Be(username);
}
[Then("the account response should be for a different account than the registered one")]
public void ThenTheAccountResponseShouldBeForADifferentAccountThanTheRegisteredOne()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.GetProperty("userAccountId")
.GetGuid()
.Should()
.NotBe(GetRegisteredUserId());
}
}

View File

@@ -1,7 +1,7 @@
using API.Core; using API.Core;
using API.Specs.Mocks; using API.Specs.Mocks;
using Features.Emails.Services; using Features.Emails.Services;
using Infrastructure.Email; using Features.ImageUploads.Services;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -33,6 +33,15 @@ internal class TestApiFactory : WebApplicationFactory<Program>
services.Remove(emailDispatcherDescriptor); services.Remove(emailDispatcherDescriptor);
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>(); services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IFileStorageProvider)
);
if (fileStorageDescriptor != null)
services.Remove(fileStorageDescriptor);
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
}); });
} }
} }

View File

@@ -19,12 +19,8 @@
<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="/Configuration/">
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" /> <Project Path="Configuration/Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder> </Folder>
<Folder Name="/Features/" /> <Folder Name="/Features/" />
<Folder Name="/Features/Users/"> <Folder Name="/Features/Users/">
@@ -51,5 +47,6 @@
<Folder Name="/Shared/"> <Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" /> <Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />
<Project Path="Shared/Shared.Application/Shared.Application.csproj" /> <Project Path="Shared/Shared.Application/Shared.Application.csproj" />
<Project Path="Shared/Shared.Application.Tests/Shared.Application.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Configuration</RootNamespace> <RootNamespace>Configuration</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,6 +1,6 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Infrastructure.Configuration; namespace Configuration;
public static class ConfigurationHelpers public static class ConfigurationHelpers
{ {

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Configuration; namespace Configuration;
public static class ConfigurationKeys public static class ConfigurationKeys
{ {
@@ -23,6 +23,14 @@ public static class ConfigurationKeys
public const string SmtpFromName = "SMTP_FROM_NAME"; public const string SmtpFromName = "SMTP_FROM_NAME";
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL"; public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
/// <summary>
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
/// the container-internal endpoint is only usable by callers that can resolve that host.
/// </summary>
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID"; public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY"; public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET"; public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";

View File

@@ -21,6 +21,6 @@
/> />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,4 @@
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -1,4 +1,4 @@
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -19,7 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="scripts/**/*.sql" /> <EmbeddedResource Include="scripts/**/*.sql" />

View File

@@ -1,5 +1,5 @@
using Database.Connection; using Database.Connection;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" /> <PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="SkiaSharp" Version="4.151.1" /> <PackageReference Include="SkiaSharp" Version="4.151.1" />
@@ -24,7 +24,6 @@
<ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None <None

View File

@@ -10,7 +10,6 @@ using Features.Locations.Queries.GetCountry;
using Features.Users.Commands.Profile.UploadAvatar; using Features.Users.Commands.Profile.UploadAvatar;
using Features.Users.DependencyInjection; using Features.Users.DependencyInjection;
using Features.Users.Services; using Features.Users.Services;
using Infrastructure.FileUpload;
using MediatR; using MediatR;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -20,7 +19,7 @@ namespace Database.Seed;
internal class Program internal class Program
{ {
private static IMediator CreateMediator() private static ServiceProvider BuildServiceProvider()
{ {
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
@@ -31,7 +30,6 @@ internal class Program
.AddFeaturesLocations() .AddFeaturesLocations()
.AddFeaturesUsers() .AddFeaturesUsers()
.AddFeaturesPhotoUpload() .AddFeaturesPhotoUpload()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
.AddScoped<ITokenService, NoOpTokenService>() .AddScoped<ITokenService, NoOpTokenService>()
.AddMediatR(cfg => .AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>() cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
@@ -40,11 +38,10 @@ internal class Program
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>() .RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
); );
using ServiceProvider provider = services.BuildServiceProvider(); return services.BuildServiceProvider();
return provider.GetRequiredService<IMediator>();
} }
public static async Task Main() public static async Task<int> Main()
{ {
using CancellationTokenSource cts = new(); using CancellationTokenSource cts = new();
Console.CancelKeyPress += (_, e) => Console.CancelKeyPress += (_, e) =>
@@ -56,20 +53,24 @@ internal class Program
try try
{ {
IMediator mediator = CreateMediator(); await using ServiceProvider provider = BuildServiceProvider();
IMediator mediator = provider.GetRequiredService<IMediator>();
SeedRepository reader = new SeedRepository( SeedRepository reader = new SeedRepository(
connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" connectionString: "Data Source=SeedData/biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
); );
await new BiergartenDataSeeder(mediator, reader).Run(cts.Token); await new BiergartenDataSeeder(mediator, reader).Run(cts.Token);
return 0;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]"); AnsiConsole.MarkupLine("[yellow]Seeding cancelled.[/]");
return 1;
} }
catch (Exception ex) catch (Exception ex)
{ {
AnsiConsole.MarkupLine("[red]Seeding failed.[/]"); AnsiConsole.MarkupLine("[red]Seeding failed.[/]");
AnsiConsole.WriteException(ex); AnsiConsole.WriteException(ex);
return 1;
} }
} }
} }

View File

@@ -58,13 +58,13 @@ public sealed class SeedRepository : IDisposable
} }
private sealed record BreweryRow( private sealed record BreweryRow(
int Id, long Id,
string NameEn, string NameEn,
string DescriptionEn, string DescriptionEn,
string NameLocal, string NameLocal,
string DescriptionLocal, string DescriptionLocal,
int AddressId, long AddressId,
int CityId, long CityId,
double Longitude, double Longitude,
double Latitude, double Latitude,
string AddressLine1, string AddressLine1,
@@ -101,27 +101,27 @@ public sealed class SeedRepository : IDisposable
return [.. rows.Select(row => new BreweryResult return [.. rows.Select(row => new BreweryResult
{ {
Id = row.Id, Id = (int)row.Id,
NameEn = row.NameEn, NameEn = row.NameEn,
DescriptionEn = row.DescriptionEn, DescriptionEn = row.DescriptionEn,
NameLocal = row.NameLocal, NameLocal = row.NameLocal,
DescriptionLocal = row.DescriptionLocal, DescriptionLocal = row.DescriptionLocal,
Address = new BreweryAddress Address = new BreweryAddress
{ {
Id = row.AddressId, Id = (int)row.AddressId,
CityId = row.CityId, CityId = (int)row.CityId,
BreweryId = row.Id, BreweryId = (int)row.Id,
Longitude = row.Longitude, Longitude = row.Longitude,
Latitude = row.Latitude, Latitude = row.Latitude,
AddressLine1 = row.AddressLine1, AddressLine1 = row.AddressLine1,
PostalCode = row.PostalCode, PostalCode = row.PostalCode,
City = cities[row.CityId], City = cities[(int)row.CityId],
}, },
})]; })];
} }
private sealed record UserRow( private sealed record UserRow(
int Id, long Id,
string FirstName, string FirstName,
string LastName, string LastName,
string Gender, string Gender,
@@ -130,8 +130,8 @@ public sealed class SeedRepository : IDisposable
double ActivityWeight, double ActivityWeight,
string Email, string Email,
string DateOfBirth, string DateOfBirth,
int AddressId, long AddressId,
int CityId, long CityId,
double Longitude, double Longitude,
double Latitude double Latitude
); );
@@ -172,16 +172,16 @@ public sealed class SeedRepository : IDisposable
DateOfBirth = row.DateOfBirth, DateOfBirth = row.DateOfBirth,
Address = new UserAddress Address = new UserAddress
{ {
Id = row.AddressId, Id = (int)row.AddressId,
CityId = row.CityId, CityId = (int)row.CityId,
UserId = row.Id, UserId = (int)row.Id,
Longitude = row.Longitude, Longitude = row.Longitude,
Latitude = row.Latitude, Latitude = row.Latitude,
City = cities[row.CityId], City = cities[(int)row.CityId],
}, },
User = new UserResult User = new UserResult
{ {
Id = row.Id, Id = (int)row.Id,
FirstName = row.FirstName, FirstName = row.FirstName,
LastName = row.LastName, LastName = row.LastName,
Gender = row.Gender, Gender = row.Gender,
@@ -193,7 +193,7 @@ public sealed class SeedRepository : IDisposable
} }
private sealed record CityRow( private sealed record CityRow(
int Id, long Id,
string CityName, string CityName,
string StateProvince, string StateProvince,
string Iso31662, string Iso31662,
@@ -231,10 +231,10 @@ public sealed class SeedRepository : IDisposable
IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command); IEnumerable<CityRow> rows = await connection.QueryAsync<CityRow>(command);
return rows.ToDictionary( return rows.ToDictionary(
row => row.Id, row => (int)row.Id,
row => new City row => new City
{ {
Id = row.Id, Id = (int)row.Id,
CityName = row.CityName, CityName = row.CityName,
StateProvinceName = row.StateProvince, StateProvinceName = row.StateProvince,
ISO_3166_2 = row.Iso31662, ISO_3166_2 = row.Iso31662,

View File

@@ -5,11 +5,11 @@ COPY . .
RUN dotnet restore "Biergarten.API.slnx" RUN dotnet restore "Biergarten.API.slnx"
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is # Runs every Features.*.Tests and Shared.*.Tests project (unit + DbMocker-backed repository
# excluded here since it's a separate BDD/integration suite, run by its own compose service # tests). API.Specs is excluded here since it's a separate BDD/integration suite, run by its own
# against the live, seeded database. # compose service against the live, seeded database.
FROM build AS final FROM build AS final
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
RUN mkdir -p /app/test-results 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"] ENTRYPOINT ["sh", "-c", "set -e; for proj in Features/*.Tests Shared/*.Tests; do dotnet test \"$proj\" -c ${BUILD_CONFIGURATION:-Release} --logger \"trx;LogFileName=/app/test-results/$(basename \"$proj\").trx\"; done"]

View File

@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace Domain.Entities; namespace Domain.Entities;
public sealed class UserAccount public sealed class UserAccount
@@ -19,4 +21,11 @@ public sealed class UserAccount
// Navigation properties // Navigation properties
public UserCredential? UserCredential { get; set; } public UserCredential? UserCredential { get; set; }
public UserProfile? UserProfile { get; set; }
// GET /api/user/authenticated serializes this entity directly, and the avatar graph carries the
// storage key and uploader of the underlying Photo. Clients read the avatar from
// PublicUserProfileDto.AvatarUrl, which is a signed URL, so keep this one out of the response.
[JsonIgnore]
public UserAvatar? UserAvatar { get; set; }
} }

View File

@@ -1,6 +1,5 @@
using Features.Emails.Services; using Features.Emails.Services;
using Features.Emails.Templates.Rendering; using Features.Emails.Templates.Rendering;
using Infrastructure.Email;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;

View File

@@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference <PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions" Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1" Version="10.0.1"
@@ -14,8 +15,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
<ProjectReference Include="..\Features.Emails.Templates\Features.Emails.Templates.csproj" /> <ProjectReference Include="..\Features.Emails.Templates\Features.Emails.Templates.csproj" />
<ProjectReference Include="..\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\Features.Users\Features.Users.csproj" />

View File

@@ -1,6 +1,5 @@
using Features.Emails.Templates.Rendering; using Features.Emails.Templates.Rendering;
using Infrastructure.Configuration; using Configuration;
using Infrastructure.Email;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Features.Emails.Services; namespace Features.Emails.Services;

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.Email; namespace Features.Emails.Services;
/// <summary> /// <summary>
/// Service for sending emails. /// Service for sending emails.

View File

@@ -1,10 +1,10 @@
using Infrastructure.Configuration; using Configuration;
using MailKit.Net.Smtp; using MailKit.Net.Smtp;
using MailKit.Security; using MailKit.Security;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using MimeKit; using MimeKit;
namespace Infrastructure.Email; namespace Features.Emails.Services;
/// <summary> /// <summary>
/// SMTP email service implementation using MailKit. /// SMTP email service implementation using MailKit.

View File

@@ -1,8 +1,8 @@
using Domain.Entities; using Domain.Entities;
using Features.ImageUploads.Commands.UploadPhoto; using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.Repository; using Features.ImageUploads.Repository;
using Features.ImageUploads.Services;
using FluentAssertions; using FluentAssertions;
using Infrastructure.FileUpload;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Moq; using Moq;

View File

@@ -1,6 +1,6 @@
using Domain.Entities; using Domain.Entities;
using Features.ImageUploads.Repository; using Features.ImageUploads.Repository;
using Infrastructure.FileUpload; using Features.ImageUploads.Services;
using MediatR; using MediatR;
namespace Features.ImageUploads.Commands.UploadPhoto; namespace Features.ImageUploads.Commands.UploadPhoto;

View File

@@ -1,4 +1,5 @@
using Features.ImageUploads.Repository; using Features.ImageUploads.Repository;
using Features.ImageUploads.Services;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace Features.ImageUploads.DependencyInjection; namespace Features.ImageUploads.DependencyInjection;
@@ -6,11 +7,16 @@ namespace Features.ImageUploads.DependencyInjection;
/// <summary>Registers the services required by the PhotoUpload feature.</summary> /// <summary>Registers the services required by the PhotoUpload feature.</summary>
public static class FeaturesPhotoUploadServiceCollectionExtensions public static class FeaturesPhotoUploadServiceCollectionExtensions
{ {
/// <summary>Registers <see cref="IPhotoUploadRepository" /> and its implementation.</summary> /// <summary>
/// Registers <see cref="IPhotoUploadRepository" /> and <see cref="IFileStorageProvider" /> and their
/// implementations.
/// </summary>
/// <returns><paramref name="services" />, for chaining.</returns> /// <returns><paramref name="services" />, for chaining.</returns>
public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services) public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services)
{ {
services.AddScoped<IPhotoUploadRepository, PhotoUploadRepository>(); services
.AddScoped<IPhotoUploadRepository, PhotoUploadRepository>()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
return services; return services;
} }
} }

View File

@@ -10,11 +10,15 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
</ItemGroup>
<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="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.FileUpload; namespace Features.ImageUploads.Services;
/// <summary> /// <summary>
/// Service for storing and retrieving uploaded files in S3-compatible object storage. /// Service for storing and retrieving uploaded files in S3-compatible object storage.

View File

@@ -1,9 +1,9 @@
using Amazon.S3; using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using Infrastructure.Configuration; using Configuration;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace Infrastructure.FileUpload; namespace Features.ImageUploads.Services;
/// <summary> /// <summary>
/// File storage service backed by an S3-compatible object store (e.g. SeaweedFS, AWS S3). /// File storage service backed by an S3-compatible object store (e.g. SeaweedFS, AWS S3).
@@ -13,11 +13,14 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
{ {
private readonly string _bucket; private readonly string _bucket;
private readonly IAmazonS3 _client; private readonly IAmazonS3 _client;
private readonly IAmazonS3 _presignClient;
private readonly Protocol _presignProtocol;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage /// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>, /// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />. /// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>, and the optional
/// <c>SEAWEEDFS_PUBLIC_URL</c>) from <paramref name="configuration" />.
/// </summary> /// </summary>
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// Thrown when any of the required configuration values is not set. /// Thrown when any of the required configuration values is not set.
@@ -45,6 +48,31 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true }; AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config); _client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
// A presigned URL is signed over its host, so one signed against the container-internal
// endpoint is only usable from inside the network. Sign downloads against the public
// endpoint instead, so a browser can fetch the object.
string? configuredPublicUrl = configuration[ConfigurationKeys.SeaweedFsPublicUrl];
string publicUrl = string.IsNullOrWhiteSpace(configuredPublicUrl)
? serviceUrl
: configuredPublicUrl;
_presignClient =
publicUrl == serviceUrl
? _client
: new AmazonS3Client(
accessKeyId,
secretAccessKey,
new AmazonS3Config { ServiceURL = publicUrl, ForcePathStyle = true }
);
// GetPreSignedUrlRequest.Protocol defaults to HTTPS and ignores the ServiceURL scheme, so
// take the scheme from the endpoint the URL is signed against.
_presignProtocol =
Uri.TryCreate(publicUrl, UriKind.Absolute, out Uri? publicUri)
&& publicUri.Scheme == Uri.UriSchemeHttp
? Protocol.HTTP
: Protocol.HTTPS;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -82,14 +110,18 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
BucketName = _bucket, BucketName = _bucket,
Key = key, Key = key,
Expires = DateTime.UtcNow.Add(expiresIn), Expires = DateTime.UtcNow.Add(expiresIn),
Protocol = _presignProtocol,
}; };
return _client.GetPreSignedURL(request); return _presignClient.GetPreSignedURL(request);
} }
/// <inheritdoc/> /// <inheritdoc/>
public void Dispose() public void Dispose()
{ {
if (!ReferenceEquals(_presignClient, _client))
_presignClient.Dispose();
_client.Dispose(); _client.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }

View File

@@ -22,6 +22,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Users\Features.Users.csproj" /> <ProjectReference Include="..\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,6 @@
using Domain.Entities; using Domain.Entities;
using Features.ImageUploads.Services;
using Features.Users.Dtos;
using Features.Users.Queries.GetAllUsers; using Features.Users.Queries.GetAllUsers;
using Features.Users.Repository; using Features.Users.Repository;
using FluentAssertions; using FluentAssertions;
@@ -8,19 +10,98 @@ namespace Features.Users.Tests.Queries;
public class GetAllUsersHandlerTests public class GetAllUsersHandlerTests
{ {
private readonly Mock<IUserListRepository> _repoMock = new();
private readonly Mock<IFileStorageProvider> _storageMock = new();
private readonly GetAllUsersHandler _handler;
public GetAllUsersHandlerTests()
{
_storageMock
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
_handler = new GetAllUsersHandler(_repoMock.Object, _storageMock.Object);
}
[Fact] [Fact]
public async Task Handle_PassesLimitAndOffset_ToRepository() public async Task Handle_PassesLimitAndOffset_ToRepository()
{ {
Mock<IUserListRepository> repoMock = new(); _repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
GetAllUsersHandler handler = new(repoMock.Object);
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
IEnumerable<UserAccount> result = await handler.Handle( IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
new GetAllUsersQuery(10, 5), new GetAllUsersQuery(10, 5),
CancellationToken.None CancellationToken.None
); );
result.Should().BeEmpty(); result.Should().BeEmpty();
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once); _repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
}
[Fact]
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "someone",
FirstName = "Some",
LastName = "One",
CreatedAt = DateTime.UtcNow,
};
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
new GetAllUsersQuery(null, null),
CancellationToken.None
);
result
.Should()
.ContainSingle()
.Which.Should()
.BeEquivalentTo(user.ToPublicProfileDto(_storageMock.Object));
}
[Fact]
public async Task Handle_MapsEachUsersOwnAvatarUrl()
{
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
UserAccount withAvatar = new()
{
UserAccountId = Guid.NewGuid(),
Username = "has-avatar",
FirstName = "Has",
LastName = "Avatar",
CreatedAt = DateTime.UtcNow,
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
};
UserAccount withoutAvatar = new()
{
UserAccountId = Guid.NewGuid(),
Username = "no-avatar",
FirstName = "No",
LastName = "Avatar",
CreatedAt = DateTime.UtcNow,
};
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([withAvatar, withoutAvatar]);
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
new GetAllUsersQuery(null, null),
CancellationToken.None
);
result
.Select(dto => dto.AvatarUrl)
.Should()
.Equal($"https://storage.test/{avatarKey}?signed", string.Empty);
_storageMock.Verify(
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
Times.Once
);
_storageMock.Verify(
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
Times.Once
);
} }
} }

View File

@@ -0,0 +1,182 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.ImageUploads.Services;
using Features.Users.Dtos;
using Features.Users.Queries.GetPublicUserProfileById;
using Features.Users.Repository;
using FluentAssertions;
using Moq;
namespace Features.Users.Tests.Queries;
public class GetPublicUserProfileByIdHandlerTests
{
private readonly GetPublicUserProfileByIdHandler _handler;
private readonly Mock<IUserListRepository> _repoMock = new();
private readonly Mock<IFileStorageProvider> _storageMock = new();
public GetPublicUserProfileByIdHandlerTests()
{
_storageMock
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object, _storageMock.Object);
}
[Fact]
public async Task Handle_ReturnsPublicProfile_WhenFound()
{
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
Email = "jane@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result
.Should()
.Be(
new PublicUserProfileDto(
user.UserAccountId,
user.Username,
user.FirstName,
user.LastName,
"Drinks lagers.",
$"https://storage.test/{avatarKey}?signed",
user.CreatedAt
)
);
}
/// <summary>
/// <c>Photo.Hyperlink</c> holds an object key, so the DTO has to carry a signed URL rather than
/// the stored value: a bare key resolves against the calling page's own origin.
/// </summary>
[Fact]
public async Task Handle_SignsTheAvatarKey_RatherThanReturningIt()
{
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.AvatarUrl.Should().NotBe(avatarKey);
Uri.TryCreate(result.AvatarUrl, UriKind.Absolute, out Uri? uri).Should().BeTrue();
uri!.Scheme.Should().BeOneOf("http", "https");
uri.AbsolutePath.Should().EndWith(avatarKey);
_storageMock.Verify(
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
Times.Once
);
}
[Fact]
public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.Biography.Should().BeEmpty();
result.AvatarUrl.Should().BeEmpty();
}
[Fact]
public async Task Handle_ReturnsEmptyAvatarUrl_WhenProfileHasNoAvatar()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.Biography.Should().Be("Drinks lagers.");
result.AvatarUrl.Should().BeEmpty();
}
[Fact]
public async Task Handle_ReturnsEmptyAvatarUrl_WhenAvatarHasNoPhoto()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
UserAvatar = new UserAvatar(),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.AvatarUrl.Should().BeEmpty();
_storageMock.Verify(
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
Times.Never
);
}
[Fact]
public async Task Handle_Throws_WhenNotFound()
{
Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
Func<Task<PublicUserProfileDto>> act = async () =>
await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -5,8 +5,7 @@ using Features.Users.Identity;
using Features.Users.Services; using Features.Users.Services;
using Features.Users.Tests.TestSupport; using Features.Users.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Configuration; using Configuration;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Moq; using Moq;

View File

@@ -4,8 +4,7 @@ using Domain.Exceptions;
using Features.Users.Services; using Features.Users.Services;
using Features.Users.Tests.TestSupport; using Features.Users.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;
using Infrastructure.Configuration; using Configuration;
using Infrastructure.Jwt;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Moq; using Moq;

View File

@@ -1,40 +1,74 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Users.Dtos;
using Features.Users.Queries.GetAllUsers; using Features.Users.Queries.GetAllUsers;
using Features.Users.Queries.GetPublicUserProfileById;
using Features.Users.Queries.GetUserById; using Features.Users.Queries.GetUserById;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
namespace Features.Users.Controllers; namespace Features.Users.Controllers;
/// <summary> /// <summary>
/// Exposes read endpoints for user accounts. /// Exposes read endpoints for user accounts.
/// </summary> /// </summary>
/// <remarks>
/// Every endpoint requires <c>[Authorize(AuthenticationSchemes = "JWT")]</c> except
/// <see cref="GetPublicProfile" />, which is <see cref="AllowAnonymousAttribute" /> since it
/// only returns an allowlisted DTO. <see cref="GetAuthenticated" /> serves the caller's own
/// account, taken from the access token. Neither <see cref="GetAll" /> nor
/// <see cref="GetPublicProfile" /> may expose another user's <c>Email</c> or
/// <c>DateOfBirth</c>.
/// </remarks>
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "JWT")]
public class UserController(IMediator mediator) : ControllerBase public class UserController(IMediator mediator) : ControllerBase
{ {
/// <summary>Gets a page of user accounts, ordered by creation date descending.</summary> /// <summary>Gets a page of user accounts' public profiles, ordered by creation date descending.</summary>
/// <param name="limit"><c>null</c> for no limit.</param> /// <param name="limit"><c>null</c> for no limit.</param>
/// <param name="offset"><c>null</c> for no offset.</param> /// <param name="offset"><c>null</c> for no offset.</param>
[HttpGet] [HttpGet]
public async Task<ActionResult<IEnumerable<UserAccount>>> GetAll( public async Task<ActionResult<IEnumerable<PublicUserProfileDto>>> GetAll(
[FromQuery] int? limit, [FromQuery] int? limit,
[FromQuery] int? offset [FromQuery] int? offset
) )
{ {
IEnumerable<UserAccount> users = await mediator.Send(new GetAllUsersQuery(limit, offset)); IEnumerable<PublicUserProfileDto> users = await mediator.Send(
new GetAllUsersQuery(limit, offset)
);
return Ok(users); return Ok(users);
} }
/// <summary>Gets the user account with the given ID.</summary> /// <summary>Gets the authenticated caller's own user account.</summary>
/// <exception cref="NotFoundException"> /// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID, resulting in a 404 response. /// Thrown when no user account exists for the caller's ID, resulting in a 404 response.
/// </exception> /// </exception>
[HttpGet("{id:guid}")] [HttpGet("authenticated")]
public async Task<ActionResult<UserAccount>> GetById(Guid id) public async Task<ActionResult<UserAccount>> GetAuthenticated()
{ {
UserAccount user = await mediator.Send(new GetUserByIdQuery(id)); UserAccount user = await mediator.Send(
new GetUserByIdQuery(User.GetAuthenticatedUserId())
);
return Ok(user); return Ok(user);
} }
/// <summary>Gets the public profile for the user account with the given ID.</summary>
/// <remarks>
/// This endpoint is anonymous-accessible and requires no ownership. Anyone can view any
/// account's public profile. The response includes only fields that are safe to show publicly.
/// Unlike <see cref="GetAuthenticated"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>.
/// </remarks>
/// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID. The API returns HTTP status 404.
/// </exception>
[HttpGet("{id:guid}/profile")]
[AllowAnonymous]
public async Task<ActionResult<PublicUserProfileDto>> GetPublicProfile(Guid id)
{
PublicUserProfileDto profile = await mediator.Send(new GetPublicUserProfileByIdQuery(id));
return Ok(profile);
}
} }

View File

@@ -1,7 +1,6 @@
using Features.Users.Identity; using Features.Users.Identity;
using Features.Users.Repository; using Features.Users.Repository;
using Features.Users.Services; using Features.Users.Services;
using Infrastructure.PasswordHashing;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -19,6 +18,7 @@ public static class FeaturesUsersServiceCollectionExtensions
public static IServiceCollection AddFeaturesUsers(this IServiceCollection services) public static IServiceCollection AddFeaturesUsers(this IServiceCollection services)
{ {
services.AddScoped<ITokenService, TokenService>(); services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>(); services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
services.AddScoped<IUserListRepository, UserListRepository>(); services.AddScoped<IUserListRepository, UserListRepository>();
services.AddScoped<IUserProfileRepository, UserProfileRepository>(); services.AddScoped<IUserProfileRepository, UserProfileRepository>();

View File

@@ -0,0 +1,40 @@
using Domain.Entities;
using Features.ImageUploads.Services;
namespace Features.Users.Dtos;
/// <summary>Converts user domain entities into API response models.</summary>
public static class UserDtoMapper
{
/// <summary>
/// How long a presigned avatar URL stays valid. Long enough to outlive the page that embeds it,
/// short enough that a leaked URL stops working.
/// </summary>
public static readonly TimeSpan AvatarUrlLifetime = TimeSpan.FromHours(1);
/// <summary>Converts a user account into its public-profile representation.</summary>
/// <param name="user">The account to convert.</param>
/// <param name="fileStorageProvider">
/// Signs the download URL for the account's avatar. <c>Photo.Hyperlink</c> holds an object key
/// rather than a URL, so it has to be signed before a client can fetch it.
/// </param>
public static PublicUserProfileDto ToPublicProfileDto(
this UserAccount user,
IFileStorageProvider fileStorageProvider
)
{
string? avatarKey = user.UserAvatar?.Photo?.Hyperlink;
return new PublicUserProfileDto(
UserAccountId : user.UserAccountId,
Username : user.Username,
FirstName : user.FirstName,
LastName : user.LastName,
Biography : user.UserProfile?.Biography ?? string.Empty,
AvatarUrl : string.IsNullOrWhiteSpace(avatarKey)
? string.Empty
: fileStorageProvider.GetPresignedUrl(avatarKey, AvatarUrlLifetime),
CreatedAt : user.CreatedAt
);
}
}

View File

@@ -0,0 +1,12 @@
namespace Features.Users.Dtos;
/// <summary>Represents the subset of a user account that is safe to expose on a public profile.</summary>
public record PublicUserProfileDto(
Guid UserAccountId,
string Username,
string FirstName,
string LastName,
string Biography,
string AvatarUrl,
DateTime CreatedAt
);

View File

@@ -14,12 +14,16 @@
<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.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Configuration\Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.16.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
</ItemGroup>
</Project> </Project>

View File

@@ -3,7 +3,7 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
namespace Infrastructure.PasswordHashing; namespace Features.Users.Identity;
/// <summary> /// <summary>
/// Hashes and verifies passwords using the Argon2id algorithm via Konscious.Security.Cryptography. /// Hashes and verifies passwords using the Argon2id algorithm via Konscious.Security.Cryptography.

View File

@@ -1,4 +1,3 @@
using Infrastructure.PasswordHashing;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Features.Users.Identity; namespace Features.Users.Identity;

View File

@@ -1,4 +1,4 @@
namespace Infrastructure.PasswordHashing; namespace Features.Users.Identity;
/// <summary> /// <summary>
/// Service for hashing and verifying user passwords. /// Service for hashing and verifying user passwords.

View File

@@ -1,17 +1,22 @@
using Domain.Entities; using Domain.Entities;
using Features.ImageUploads.Services;
using Features.Users.Dtos;
using Features.Users.Repository; using Features.Users.Repository;
using MediatR; using MediatR;
namespace Features.Users.Queries.GetAllUsers; namespace Features.Users.Queries.GetAllUsers;
public class GetAllUsersHandler(IUserListRepository repository) public class GetAllUsersHandler(
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>> IUserListRepository repository,
IFileStorageProvider fileStorageProvider
) : IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
{ {
public Task<IEnumerable<UserAccount>> Handle( public async Task<IEnumerable<PublicUserProfileDto>> Handle(
GetAllUsersQuery request, GetAllUsersQuery request,
CancellationToken cancellationToken CancellationToken cancellationToken
) )
{ {
return repository.GetAllAsync(request.Limit, request.Offset); IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
return users.Select(user => user.ToPublicProfileDto(fileStorageProvider));
} }
} }

View File

@@ -1,11 +1,11 @@
using Domain.Entities; using Features.Users.Dtos;
using MediatR; using MediatR;
namespace Features.Users.Queries.GetAllUsers; namespace Features.Users.Queries.GetAllUsers;
/// <summary> /// <summary>
/// Requests a page of user accounts, ordered by creation date descending. /// Requests a page of user accounts' public profiles, ordered by creation date descending.
/// </summary> /// </summary>
/// <param name="Limit"><c>null</c> for no limit.</param> /// <param name="Limit"><c>null</c> for no limit.</param>
/// <param name="Offset"><c>null</c> for no offset.</param> /// <param name="Offset"><c>null</c> for no offset.</param>
public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<UserAccount>>; public record GetAllUsersQuery(int? Limit, int? Offset) : IRequest<IEnumerable<PublicUserProfileDto>>;

View File

@@ -0,0 +1,26 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.ImageUploads.Services;
using Features.Users.Dtos;
using Features.Users.Repository;
using MediatR;
namespace Features.Users.Queries.GetPublicUserProfileById;
public class GetPublicUserProfileByIdHandler(
IUserListRepository repository,
IFileStorageProvider fileStorageProvider
) : IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
{
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<PublicUserProfileDto> Handle(
GetPublicUserProfileByIdQuery request,
CancellationToken cancellationToken
)
{
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
if (user is null)
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
return user.ToPublicProfileDto(fileStorageProvider);
}
}

View File

@@ -0,0 +1,6 @@
using Features.Users.Dtos;
using MediatR;
namespace Features.Users.Queries.GetPublicUserProfileById;
public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest<PublicUserProfileDto>;

View File

@@ -3,4 +3,7 @@ using MediatR;
namespace Features.Users.Queries.GetUserById; namespace Features.Users.Queries.GetUserById;
/// <param name="UserAccountId">
/// The account to fetch. This query returns private fields, including <c>Email</c>.
/// </param>
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>; public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;

View File

@@ -13,38 +13,102 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
IUserListRepository IUserListRepository
{ {
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
/// <c>null</c> for an account whose profile has yet to be created, and left-joins the
/// account's active <c>Social.UserAvatar</c> (<c>ValidTo IS NULL</c>) with its
/// <c>Media.Photo</c>, so <see cref="UserAccount.UserAvatar" /> is <c>null</c> for an account
/// with no avatar.
/// </remarks>
public async Task<UserAccount?> GetByIdAsync(Guid id) public async Task<UserAccount?> GetByIdAsync(Guid id)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>( IEnumerable<UserAccount> results = await connection.QueryAsync<
UserAccount,
UserProfile,
Photo,
UserAvatar,
UserAccount
>(
""" """
SELECT SELECT
UserAccountID, ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
Username, ua.Email, ua.CreatedAt, ua.UpdatedAt, ua.DateOfBirth,
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion ua.RowVersion, up.UserProfileID, up.UserAccountID,
FROM Auth.UserAccount up.Biography, up.RowVersion, p.PhotoID, p.UploadedByID,
WHERE UserAccountID = @UserAccountId p.Hyperlink, p.UploadedAt, p.RowVersion, av.UserAvatarID,
av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
av.RowVersion
FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up
ON ua.UserAccountID = up.UserAccountID
LEFT JOIN Social.UserAvatar av
ON up.UserProfileID = av.UserProfileID
AND av.ValidTo IS NULL
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
WHERE ua.UserAccountID = @UserAccountId
""", """,
new { UserAccountId = id } MapUserRow,
new { UserAccountId = id },
splitOn: AvatarSplitOn
); );
return results.SingleOrDefault();
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks> /// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending. /// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset". /// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// Joins the same profile and active-avatar rows as <see cref="GetByIdAsync" />, both of which are
/// part of a public profile. Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is
/// consumed only as public profiles, so those fields are left at their default rather than read
/// from the database.
/// </remarks> /// </remarks>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<UserAccount>( return await connection.QueryAsync<
UserAccount,
UserProfile,
Photo,
UserAvatar,
UserAccount
>(
""" """
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion SELECT
FROM Auth.UserAccount ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
ORDER BY CreatedAt DESC ua.CreatedAt, ua.UpdatedAt, ua.RowVersion,
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion,
p.PhotoID, p.UploadedByID, p.Hyperlink, p.UploadedAt, p.RowVersion,
av.UserAvatarID, av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
av.RowVersion
FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
LEFT JOIN Social.UserAvatar av
ON up.UserProfileID = av.UserProfileID AND av.ValidTo IS NULL
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
ORDER BY ua.CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""", """,
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue } MapUserRow,
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
splitOn: AvatarSplitOn
); );
} }
private const string AvatarSplitOn = "UserProfileID,PhotoID,UserAvatarID";
private static UserAccount MapUserRow(
UserAccount user,
UserProfile? profile,
Photo? photo,
UserAvatar? avatar
)
{
user.UserProfile = profile;
avatar?.Photo = photo;
user.UserAvatar = avatar;
return user;
}
} }

View File

@@ -1,6 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
namespace Infrastructure.Jwt; namespace Features.Users.Services;
/// <summary> /// <summary>
/// Service for generating and validating JSON Web Tokens (JWTs) used for authentication. /// Service for generating and validating JSON Web Tokens (JWTs) used for authentication.

View File

@@ -5,7 +5,7 @@ using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames; using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace Infrastructure.Jwt; namespace Features.Users.Services;
/// <summary> /// <summary>
/// Generates and validates HMAC-SHA256 signed JWTs using <see cref="JsonWebTokenHandler" />. /// Generates and validates HMAC-SHA256 signed JWTs using <see cref="JsonWebTokenHandler" />.

View File

@@ -2,8 +2,7 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Users.Identity; using Features.Users.Identity;
using Infrastructure.Configuration; using Configuration;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Email</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.FileUpload</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.Jwt</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.2.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.PasswordHashing</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using MediatR;
namespace Shared.Application.Tests.Behaviors;
// ReSharper disable NotAccessedPositionalProperty.Global
public sealed record TestRequest(string Username, string Password) : IRequest<TestResponse>;
public sealed record TestResponse(string Value);
// ReSharper restore NotAccessedPositionalProperty.Global

View File

@@ -0,0 +1,110 @@
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using MediatR;
using Microsoft.Extensions.Logging;
using Moq;
using Shared.Application.Behaviors;
namespace Shared.Application.Tests.Behaviors;
public class UnhandledExceptionLoggingBehaviorTests
{
private readonly Mock<
ILogger<UnhandledExceptionLoggingBehavior<TestRequest, TestResponse>>
> _loggerMock = new();
private UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> CreateBehavior()
{
_loggerMock.Setup(l => l.IsEnabled(LogLevel.Error)).Returns(true);
return new(_loggerMock.Object);
}
[Fact]
public async Task Handle_WhenHandlerThrowsGenericException_LogsOnceAndRethrows()
{
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "pa$$word123");
InvalidOperationException thrown = new("Something went wrong.");
Task<TestResponse> next(CancellationToken _ = default) => throw thrown;
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
(await act.Should().ThrowAsync<InvalidOperationException>())
.Which.Should()
.BeSameAs(thrown);
VerifyLogError(Times.Once());
}
[Fact]
public async Task Handle_WhenValidationExceptionThrown_DoesNotLogAndRethrows()
{
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "pa$$word123");
ValidationException thrown = new([new ValidationFailure("Username", "is required")]);
Task<TestResponse> next(CancellationToken _ = default) => throw thrown;
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>()).Which.Should().BeSameAs(thrown);
VerifyLogError(Times.Never());
}
[Fact]
public async Task Handle_WhenHandlerSucceeds_DoesNotLogAndReturnsResponse()
{
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "pa$$word123");
TestResponse response = new("ok");
Task<TestResponse> next(CancellationToken _ = default) => Task.FromResult(response);
TestResponse result = await behavior.Handle(request, next, CancellationToken.None);
result.Should().BeSameAs(response);
VerifyLogError(Times.Never());
}
[Fact]
public async Task Handle_WhenHandlerThrows_RedactsSensitiveFieldsInLoggedPayload()
{
UnhandledExceptionLoggingBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "pa$$word123");
Task<TestResponse> next(CancellationToken _ = default) =>
throw new InvalidOperationException("Something went wrong.");
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
string loggedMessage = CaptureLoggedMessage();
loggedMessage.Should().Contain("aaron").And.NotContain("pa$$word123");
}
private void VerifyLogError(Times times) =>
_loggerMock.Verify(
l =>
l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
),
times
);
private string CaptureLoggedMessage()
{
IInvocation? invocation = _loggerMock
.Invocations.Where(i => i.Method.Name == nameof(ILogger.Log))
.Should()
.ContainSingle()
.Subject;
object state = invocation.Arguments[2];
object exception = invocation.Arguments[3];
object formatter = invocation.Arguments[4];
System.Reflection.MethodInfo invokeMethod = formatter.GetType().GetMethod("Invoke")!;
return (string)invokeMethod.Invoke(formatter, [state, exception])!;
}
}

View File

@@ -0,0 +1,179 @@
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using MediatR;
using Moq;
using Shared.Application.Behaviors;
namespace Shared.Application.Tests.Behaviors;
public class ValidationBehaviorTests
{
private static ValidationBehavior<TestRequest, TestResponse> CreateBehavior(
params IValidator<TestRequest>[] validators
) => new(validators);
[Fact]
public async Task Handle_WhenRequestPassesAllValidators_InvokesNextUnchanged()
{
Mock<IValidator<TestRequest>> validatorMock = new();
validatorMock
.Setup(v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult());
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
validatorMock.Object
);
TestRequest request = new("aaron", "pa$$word123");
TestResponse response = new("ok");
bool nextInvoked = false;
Task<TestResponse> next(CancellationToken _ = default)
{
nextInvoked = true;
return Task.FromResult(response);
}
TestResponse result = await behavior.Handle(request, next, CancellationToken.None);
nextInvoked.Should().BeTrue();
result.Should().BeSameAs(response);
}
[Fact]
public async Task Handle_WhenValidatorReportsFailures_ThrowsValidationExceptionAndNeverInvokesNext()
{
ValidationFailure failure = new("Username", "is required");
Mock<IValidator<TestRequest>> validatorMock = new();
validatorMock
.Setup(v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult([failure]));
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
validatorMock.Object
);
TestRequest request = new("", "pa$$word123");
bool nextInvoked = false;
Task<TestResponse> next(CancellationToken _ = default)
{
nextInvoked = true;
return Task.FromResult(new TestResponse("ok"));
}
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should()
.ContainSingle()
.Which.Should()
.BeSameAs(failure);
nextInvoked.Should().BeFalse();
}
[Fact]
public async Task Handle_WhenMultipleValidatorsAreRegistered_RunsAllOfThem()
{
Mock<IValidator<TestRequest>> firstValidatorMock = new();
firstValidatorMock
.Setup(v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult());
Mock<IValidator<TestRequest>> secondValidatorMock = new();
secondValidatorMock
.Setup(v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult());
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
firstValidatorMock.Object,
secondValidatorMock.Object
);
TestRequest request = new("aaron", "pa$$word123");
static Task<TestResponse> next(CancellationToken _ = default) =>
Task.FromResult(new TestResponse("ok"));
await behavior.Handle(request, next, CancellationToken.None);
firstValidatorMock.Verify(
v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
),
Times.Once()
);
secondValidatorMock.Verify(
v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
),
Times.Once()
);
}
[Fact]
public async Task Handle_WhenMultipleValidatorsReportFailures_AggregatesErrorsFromAll()
{
ValidationFailure firstFailure = new("Username", "is required");
ValidationFailure secondFailure = new("Username", "must be at least 3 characters");
Mock<IValidator<TestRequest>> firstValidatorMock = new();
firstValidatorMock
.Setup(v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult([firstFailure]));
Mock<IValidator<TestRequest>> secondValidatorMock = new();
secondValidatorMock
.Setup(v =>
v.ValidateAsync(
It.IsAny<ValidationContext<TestRequest>>(),
It.IsAny<CancellationToken>()
)
)
.ReturnsAsync(new ValidationResult([secondFailure]));
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior(
firstValidatorMock.Object,
secondValidatorMock.Object
);
TestRequest request = new("", "pa$$word123");
Task<TestResponse> next(CancellationToken _ = default) =>
Task.FromResult(new TestResponse("ok"));
Func<Task> act = async () => await behavior.Handle(request, next, CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should()
.BeEquivalentTo([firstFailure, secondFailure]);
}
[Fact]
public async Task Handle_WhenNoValidatorsAreRegistered_InvokesNext()
{
ValidationBehavior<TestRequest, TestResponse> behavior = CreateBehavior();
TestRequest request = new("aaron", "pa$$word123");
TestResponse response = new("ok");
Task<TestResponse> next(CancellationToken _ = default) => Task.FromResult(response);
TestResponse result = await behavior.Handle(request, next, CancellationToken.None);
result.Should().BeSameAs(response);
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Shared.Application.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="7.2.0" />
<PackageReference Include="FluentValidation" Version="11.3.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,107 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.Logging;
namespace Shared.Application.Behaviors;
public abstract class JsonLogger
{
private static readonly string[] SensitiveFieldNames = ["password", "token"];
protected static readonly JsonSerializerOptions SerializerOptions = new()
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { RedactSensitiveProperties },
},
};
private static void RedactSensitiveProperties(JsonTypeInfo typeInfo)
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (JsonPropertyInfo property in typeInfo.Properties)
{
if (property.PropertyType != typeof(string))
continue;
if (
SensitiveFieldNames.Any(name =>
property.Name.Contains(name, StringComparison.OrdinalIgnoreCase)
)
)
property.Get = static _ => "***REDACTED***";
}
}
}
/// <summary>
/// MediatR pipeline behavior that logs unhandled exceptions together with the
/// request type and
/// payload, then rethrows them unchanged for <c>GlobalExceptionFilter</c> to
/// map to a status code.
/// </summary>
public partial class UnhandledExceptionLoggingBehavior<TRequest, TResponse>
: JsonLogger,
IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> _logger;
public UnhandledExceptionLoggingBehavior(
ILogger<UnhandledExceptionLoggingBehavior<TRequest, TResponse>> logger
)
{
_logger = logger;
}
/// <inheritdoc />
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken
)
{
try
{
return await next(cancellationToken);
}
catch (ValidationException)
{
// Expected: ValidationBehavior already reports these as request errors
throw;
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
LogUnhandledException(ex, typeof(TRequest).Name, Serialize(request));
throw;
}
}
private static string Serialize(TRequest request)
{
try
{
return JsonSerializer.Serialize(request, SerializerOptions);
}
catch (Exception)
{
return "<unable to serialize request payload>";
}
}
[LoggerMessage(
Level = LogLevel.Error,
Message = "Unhandled exception while processing {RequestName}: {RequestPayload}"
)]
private partial void LogUnhandledException(
Exception ex,
string requestName,
string requestPayload
);
}

View File

@@ -5,14 +5,15 @@ using MediatR;
namespace Shared.Application.Behaviors; namespace Shared.Application.Behaviors;
/// <summary> /// <summary>
/// MediatR pipeline behavior that runs registered FluentValidation validators before the handler, /// MediatR pipeline behavior that runs registered FluentValidation validators
/// before the handler,
/// throwing <see cref="ValidationException" /> if any report failures. /// throwing <see cref="ValidationException" /> if any report failures.
/// </summary> /// </summary>
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators) public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse> where TRequest : IRequest<TResponse>
{ {
/// <inheritdoc/> /// <inheritdoc />
public async Task<TResponse> Handle( public async Task<TResponse> Handle(
TRequest request, TRequest request,
RequestHandlerDelegate<TResponse> next, RequestHandlerDelegate<TResponse> next,
@@ -20,7 +21,7 @@ public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TReq
) )
{ {
if (!validators.Any()) if (!validators.Any())
return await next(); return await next(cancellationToken);
ValidationContext<TRequest> context = new(request); ValidationContext<TRequest> context = new(request);
@@ -34,6 +35,6 @@ public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TReq
if (failures.Count > 0) if (failures.Count > 0)
throw new ValidationException(failures); throw new ValidationException(failures);
return await next(); return await next(cancellationToken);
} }
} }

View File

@@ -7,7 +7,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" Version="12.4.1" /> <PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="FluentValidation" Version="11.3.0" /> <PackageReference Include="FluentValidation" Version="11.3.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -70,6 +70,7 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -72,6 +72,7 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
@@ -110,6 +111,7 @@ services:
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -84,6 +84,7 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -0,0 +1,33 @@
# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed
name: biergarten-prod-seed
services:
database.seed:
env_file: ".env.prod"
image: database.seed
container_name: prod-env-database-seed
build:
context: ./backend
dockerfile: Database/Database.Seed/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: "no"
networks:
- prodnet
networks:
prodnet:
name: biergarten-prodnet
external: true

View File

@@ -6,10 +6,10 @@ services:
container_name: prod-env-sqlserver container_name: prod-env-sqlserver
environment: environment:
ACCEPT_EULA: "Y" ACCEPT_EULA: "Y"
SA_PASSWORD: "${DB_PASSWORD}" MSSQL_SA_PASSWORD: "${DB_PASSWORD}"
MSSQL_PID: "Express" MSSQL_PID: "Express"
volumes: volumes:
- sqlserverdata-prod:/var/opt/mssql - biergartendata-prod:/var/opt/mssql
healthcheck: healthcheck:
test: test:
[ [
@@ -20,29 +20,8 @@ services:
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 30s start_period: 30s
networks: restart: unless-stopped
- prodnet stop_grace_period: 60s
database.migrations:
env_file: ".env.prod"
image: database.migrations
container_name: prod-env-database-migrations
depends_on:
sqlserver:
condition: service_healthy
build:
context: ./backend
dockerfile: Database/Database.Migrations/Dockerfile
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
environment:
DOTNET_RUNNING_IN_CONTAINER: "true"
DB_SERVER: "${DB_SERVER}"
DB_NAME: "${DB_NAME}"
DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}"
restart: "no"
networks: networks:
- prodnet - prodnet
@@ -51,7 +30,7 @@ services:
image: api.core image: api.core
container_name: prod-env-api-core container_name: prod-env-api-core
depends_on: depends_on:
sqlserver: seaweedfs:
condition: service_healthy condition: service_healthy
build: build:
context: ./backend context: ./backend
@@ -59,8 +38,6 @@ services:
args: args:
BUILD_CONFIGURATION: Release BUILD_CONFIGURATION: Release
APP_UID: 1000 APP_UID: 1000
# No published ports: only the frontend service is reachable from outside
# the Docker network in production. api.core stays internal to prodnet.
environment: environment:
ASPNETCORE_ENVIRONMENT: "Production" ASPNETCORE_ENVIRONMENT: "Production"
ASPNETCORE_URLS: "http://0.0.0.0:8080" ASPNETCORE_URLS: "http://0.0.0.0:8080"
@@ -73,11 +50,46 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}" REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}" CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}" WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
healthcheck:
test:
[
"CMD",
"bash",
"-c",
'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; head -1 <&3 | grep -q " 200 "',
]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped restart: unless-stopped
networks: networks:
- prodnet - prodnet
seaweedfs:
env_file: ".env.prod"
image: chrislusf/seaweedfs
container_name: prod-env-seaweedfs
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes: volumes:
- nuget-cache-prod:/root/.nuget/packages - seaweedfsdata-prod:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
networks:
- prodnet
frontend: frontend:
env_file: ".env.prod" env_file: ".env.prod"
@@ -85,27 +97,34 @@ services:
container_name: prod-env-frontend container_name: prod-env-frontend
depends_on: depends_on:
api.core: api.core:
condition: service_started condition: service_healthy
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "3000:3000" - "127.0.0.1:5656:5656"
environment: environment:
NODE_ENV: "production" NODE_ENV: "production"
PORT: "3000" PORT: "5656"
API_BASE_URL: "http://api.core:8080" API_BASE_URL: "http://api.core:8080"
SESSION_SECRET: "${SESSION_SECRET}" SESSION_SECRET: "${SESSION_SECRET}"
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
restart: unless-stopped restart: unless-stopped
networks: networks:
- prodnet - prodnet
volumes: volumes:
sqlserverdata-prod: biergartendata-prod:
driver: local external: true
nuget-cache-prod: seaweedfsdata-prod:
driver: local external: true
networks: networks:
prodnet: prodnet:
name: biergarten-prodnet
driver: bridge driver: bridge

View File

@@ -70,6 +70,7 @@ services:
DB_USER: "${DB_USER}" DB_USER: "${DB_USER}"
DB_PASSWORD: "${DB_PASSWORD}" DB_PASSWORD: "${DB_PASSWORD}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}" SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}" SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}" SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}" SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

View File

@@ -34,38 +34,38 @@ h6,
--color-base-100: oklch(96% 0.012 82); /* warm parchment */ --color-base-100: oklch(96% 0.012 82); /* warm parchment */
--color-base-200: oklch(92% 0.018 80); /* brushed paper */ --color-base-200: oklch(92% 0.018 80); /* brushed paper */
--color-base-300: oklch(87% 0.025 78); /* tinted linen */ --color-base-300: oklch(87% 0.025 78); /* tinted linen */
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */ --color-base-content: oklch(28% 0.025 58); /* dark brown ink */
--color-primary: oklch(65% 0.085 62); /* mellow amber */ --color-primary: oklch(47.5% 0.085 62); /* mellow amber */
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */ --color-primary-content: oklch(97% 0.02 62); /* warm near-white */
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */ --color-secondary: oklch(42% 0.05 42); /* softened mahogany */
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */ --color-secondary-content: oklch(96% 0.01 76); /* off-white */
--color-accent: oklch(93% 0.015 90); /* frothy cream */ --color-accent: oklch(93% 0.015 90); /* frothy cream */
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */ --color-accent-content: oklch(28% 0.025 58); /* dark brown */
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */ --color-neutral: oklch(28% 0.02 46); /* warm roast dark */
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */ --color-neutral-content: oklch(92% 0.012 80); /* pale parchment */
--color-info: oklch(46% 0.065 145); /* muted hop green */ --color-info: oklch(46% 0.065 145); /* muted hop green */
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */ --color-info-content: oklch(97% 0.008 145); /* near-white */
--color-success: oklch(70% 0.06 122); /* soft barley gold */ --color-success: oklch(46.5% 0.06 122); /* soft barley gold */
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */ --color-success-content: oklch(97% 0.02 122); /* warm near-white */
--color-warning: oklch(72% 0.09 56); /* toned amber */ --color-warning: oklch(47.5% 0.09 56); /* toned amber */
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */ --color-warning-content: oklch(97% 0.02 56); /* warm near-white */
--color-error: oklch(54% 0.09 22); /* restrained cherry */ --color-error: oklch(48% 0.09 22); /* restrained cherry */
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */ --color-error-content: oklch(97% 0.006 15); /* near-white */
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */ --color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */ --color-surface-content: oklch(28% 0.025 58); /* dark brown */
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */ --color-muted: oklch(42% 0.055 62); /* amber-brown */
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */ --color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */ --color-highlight-content: oklch(22% 0.025 55); /* dark brown */
--radius-selector: 0.375rem; --radius-selector: 0.375rem;
--radius-field: 0.5rem; --radius-field: 0.5rem;
@@ -91,38 +91,38 @@ h6,
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */ --color-base-100: oklch(14% 0.006 45); /* charred barrel black */
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */ --color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
--color-base-300: oklch(23% 0.01 42); /* deep brown */ --color-base-300: oklch(23% 0.01 42); /* deep brown */
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */ --color-base-content: oklch(88% 0.008 75); /* warm off-white */
--color-primary: oklch(68% 0.055 60); /* golden amber */ --color-primary: oklch(68% 0.055 60); /* golden amber */
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */ --color-primary-content: oklch(30.5% 0.012 50); /* warm off-white */
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */ --color-secondary: oklch(62.5% 0.035 40); /* deep mahogany ale */
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */ --color-secondary-content: oklch(23.5% 0.005 75); /* near-white */
--color-accent: oklch(82% 0.01 88); /* frothy cream head */ --color-accent: oklch(82% 0.01 88); /* frothy cream head */
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */ --color-accent-content: oklch(18% 0.012 55); /* near-black */
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */ --color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */ --color-neutral-content: oklch(88% 0.007 78); /* warm off-white */
--color-info: oklch(60% 0.04 145); /* cool hop green */ --color-info: oklch(61.5% 0.04 145); /* cool hop green */
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */ --color-info-content: oklch(23% 0.006 145); /* pale green-white */
--color-success: oklch(66% 0.038 120); /* fresh barley */ --color-success: oklch(66% 0.038 120); /* fresh barley */
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */ --color-success-content: oklch(28.5% 0.012 120); /* pale barley-white */
--color-warning: oklch(70% 0.055 55); /* amber harvest */ --color-warning: oklch(70% 0.055 55); /* amber harvest */
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */ --color-warning-content: oklch(32.5% 0.012 55); /* warm near-white */
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */ --color-error: oklch(62.5% 0.06 20); /* deep cherry kriek */
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */ --color-error-content: oklch(23% 0.004 15); /* near-white */
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */ --color-surface: oklch(26% 0.012 45); /* elevated dark panel */
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */ --color-surface-content: oklch(88% 0.008 75); /* warm off-white */
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */ --color-muted: oklch(78% 0.018 72); /* warm grey */
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */ --color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */ --color-highlight-content: oklch(88% 0.008 75); /* warm off-white */
--radius-selector: 0.375rem; --radius-selector: 0.375rem;
--radius-field: 0.5rem; --radius-field: 0.5rem;
@@ -148,40 +148,38 @@ h6,
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */ --color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */ --color-base-200: oklch(17% 0.013 292); /* deep purple-black */
--color-base-300: oklch(22% 0.016 290); /* dark grape */ --color-base-300: oklch(22% 0.016 290); /* dark grape */
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */ --color-base-content: oklch(90% 0.014 300); /* pale lavender-white */
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */ --color-primary: oklch(72% 0.075 295); /* cassis berry purple */
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */ --color-primary-content: oklch(34% 0.01 295); /* pale lavender */
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */ --color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */ --color-secondary-content: oklch(30% 0.006 10); /* warm near-white */
--color-accent: oklch(75% 0.045 130); /* tart lime zest */ --color-accent: oklch(75% 0.045 130); /* tart lime zest */
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */ --color-accent-content: oklch(37.5% 0.01 130); /* near-white */
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */ --color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */ --color-neutral-content: oklch(88% 0.01 295); /* pale lavender */
--color-info: oklch(62% 0.04 250); /* muted indigo */ --color-info: oklch(62% 0.04 250); /* muted indigo */
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */ --color-info-content: oklch(23% 0.008 250); /* pale indigo-white */
--color-success: oklch(65% 0.04 145); /* elderberry green */ --color-success: oklch(65% 0.04 145); /* elderberry green */
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */ --color-success-content: oklch(27.5% 0.008 145); /* pale green-white */
--color-warning: oklch(70% 0.05 65); /* sour apricot */ --color-warning: oklch(70% 0.05 65); /* sour apricot */
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */ --color-warning-content: oklch(32.5% 0.03 65); /* near-white */
--color-error: oklch(50% 0.055 22); /* kriek red */ --color-error: oklch(62% 0.055 22); /* kriek red */
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */ --color-error-content: oklch(22% 0.006 22); /* near-white */
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */ --color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */ --color-surface-content: oklch(90% 0.014 300); /* pale lavender-white */
--color-muted: oklch( --color-muted: oklch(77.6% 0.022 300); /* desaturated lavender */
77.6% 0.022 300
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */ --color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */ --color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white */
--radius-selector: 0.5rem; --radius-selector: 0.5rem;
--radius-field: 0.5rem; --radius-field: 0.5rem;
@@ -207,38 +205,38 @@ h6,
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */ --color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */ --color-base-200: oklch(96% 0.012 114); /* pale barley wash */
--color-base-300: oklch(92% 0.019 116); /* light straw */ --color-base-300: oklch(92% 0.019 116); /* light straw */
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */ --color-base-content: oklch(20% 0.022 122); /* deep green-black */
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */ --color-primary: oklch(50% 0.085 118); /* fresh-cut barley green */
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */ --color-primary-content: oklch(97% 0.005 118); /* near-white */
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */ --color-secondary: oklch(44% 0.055 128); /* muted sage stem */
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */ --color-secondary-content: oklch(97% 0.005 128); /* near-white */
--color-accent: oklch(93% 0.03 148); /* pale morning dew */ --color-accent: oklch(93% 0.03 148); /* pale morning dew */
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */ --color-accent-content: oklch(22% 0.022 148); /* deep green */
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */ --color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */ --color-neutral-content: oklch(38.4% 0.005 118); /* near-white */
--color-info: oklch(38% 0.065 232); /* clear summer sky */ --color-info: oklch(38% 0.065 232); /* clear summer sky */
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */ --color-info-content: oklch(98% 0.005 232); /* near-white */
--color-success: oklch(38% 0.085 145); /* young shoot green */ --color-success: oklch(38% 0.085 145); /* young shoot green */
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */ --color-success-content: oklch(98% 0.005 145); /* near-white */
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */ --color-warning: oklch(51% 0.1 76); /* ripening grain amber */
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */ --color-warning-content: oklch(92.5% 0.005 72); /* warm near-white */
--color-error: oklch(52% 0.1 18); /* dusty rose red */ --color-error: oklch(51.5% 0.1 18); /* dusty rose red */
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */ --color-error-content: oklch(98% 0.005 15); /* near-white */
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */ --color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */ --color-surface-content: oklch(20% 0.022 122); /* deep green-black */
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */ --color-muted: oklch(38% 0.055 120); /* sage green */
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */ --color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */ --color-highlight-content: oklch(20% 0.022 122); /* deep green-black */
--radius-selector: 2rem; --radius-selector: 2rem;
--radius-field: 2rem; --radius-field: 2rem;
@@ -249,3 +247,33 @@ h6,
--depth: 0; --depth: 0;
--noise: 0; --noise: 0;
} }
/* WCAG 1.4.11: form-field borders at >= 3:1 against every base layer */
:is(.input, .select, .textarea):not(
.input-neutral,
.input-primary,
.input-secondary,
.input-accent,
.input-info,
.input-success,
.input-warning,
.input-error,
.select-neutral,
.select-primary,
.select-secondary,
.select-accent,
.select-info,
.select-success,
.select-warning,
.select-error,
.textarea-neutral,
.textarea-primary,
.textarea-secondary,
.textarea-accent,
.textarea-info,
.textarea-success,
.textarea-warning,
.textarea-error
) {
--input-color: color-mix(in oklab, var(--color-base-content) 56%, #0000);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@@ -21,19 +21,26 @@ const FormField = ({
}: FormFieldProps) => { }: FormFieldProps) => {
return ( return (
<Field className={className ?? 'space-y-1'}> <Field className={className ?? 'space-y-1'}>
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}> <Label
htmlFor={inputProps.id}
className={labelClassName ?? 'label text-sm font-semibold text-base-content'}
>
{label} {label}
</Label> </Label>
<input <input
{...inputProps} {...inputProps}
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`} className={
inputClassName ?? `input h-11 w-full text-base ${error ? 'input-error' : ''}`
}
/> />
{error ? ( {error ? (
<Description className={hintClassName ?? 'label text-error'}>{error}</Description> <Description className={hintClassName ?? 'text-xs text-error'}>{error}</Description>
) : hint ? ( ) : hint ? (
<Description className={hintClassName ?? 'label'}>{hint}</Description> <Description className={hintClassName ?? 'text-xs text-[var(--color-muted)]'}>
{hint}
</Description>
) : null} ) : null}
</Field> </Field>
); );

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test'; import { expect, screen, userEvent, within } from 'storybook/test';
import Navbar from './Navbar'; import Navbar from './Navbar';
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`; const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
@@ -47,8 +47,10 @@ export const Authenticated: Story = {
const userButton = canvas.getByRole('button', { name: /hans/i }); const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument(); await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton); await userEvent.click(userButton);
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument(); // MenuItems is anchor-positioned, which portals it outside canvasElement.
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument(); await expect(screen.getByRole('menuitem', { name: /my profile/i })).toBeInTheDocument();
await expect(screen.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
await expect(screen.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
}, },
}; };

View File

@@ -1,5 +1,5 @@
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'; import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
import { Link } from 'react-router'; import { Link, useLocation } from 'react-router';
import NavLinks from './NavLinks'; import NavLinks from './NavLinks';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
@@ -12,7 +12,12 @@ interface NavbarProps {
} | null; } | null;
} }
const Navbar = ({ auth }: NavbarProps) => ( const Navbar = ({ auth }: NavbarProps) => {
const { pathname } = useLocation();
const onAuthRoute = pathname === '/login' || pathname === '/register';
const showAuthActions = !auth && !onAuthRoute;
return (
<Disclosure <Disclosure
as="nav" as="nav"
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md" className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
@@ -49,15 +54,15 @@ const Navbar = ({ auth }: NavbarProps) => (
</svg> </svg>
</DisclosureButton> </DisclosureButton>
<Link to="/" className="text-xl font-bold"> <Link to="/" className="lg:text-xl text-lg font-bold">
🍺 The Biergarten App The Biergarten App
</Link> </Link>
</div> </div>
<NavLinks variant="desktop" showRegister={!auth} /> <NavLinks variant="desktop" showRegister={showAuthActions} />
<div className="navbar-end gap-2"> <div className="navbar-end gap-2">
{!auth && ( {showAuthActions && (
<Link <Link
to="/register" to="/register"
className="btn btn-ghost btn-sm hidden sm:inline-flex" className="btn btn-ghost btn-sm hidden sm:inline-flex"
@@ -72,22 +77,28 @@ const Navbar = ({ auth }: NavbarProps) => (
Dashboard Dashboard
</Link> </Link>
<UserMenu username={auth.username} /> <UserMenu
username={auth.username}
userAccountId={auth.userAccountId}
/>
</> </>
) : ( ) : (
!onAuthRoute && (
<Link to="/login" className="btn btn-primary btn-sm"> <Link to="/login" className="btn btn-primary btn-sm">
Login Login
</Link> </Link>
)
)} )}
</div> </div>
</div> </div>
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden"> <DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
<NavLinks variant="mobile" showRegister={!auth} /> <NavLinks variant="mobile" showRegister={showAuthActions} />
</DisclosurePanel> </DisclosurePanel>
</> </>
)} )}
</Disclosure> </Disclosure>
); );
};
export default Navbar; export default Navbar;

View File

@@ -3,25 +3,40 @@ import { Link } from 'react-router';
interface UserMenuProps { interface UserMenuProps {
username: string; username: string;
userAccountId: string;
} }
const UserMenu = ({ username }: UserMenuProps) => ( const UserMenu = ({ username, userAccountId }: UserMenuProps) => (
<Menu as="div" className="relative"> <Menu>
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton> <MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none"> <MenuItems
anchor="bottom end"
transition
className="menu z-60 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl [--anchor-gap:8px] focus:outline-none transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<MenuItem> <MenuItem>
{({ focus }) => ( <Link
<Link to="/dashboard" className={focus ? 'active' : ''}> to={`/users/${userAccountId}`}
Dashboard className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
My profile
</Link> </Link>
)}
</MenuItem> </MenuItem>
<MenuItem> <MenuItem>
{({ focus }) => ( <Link
<Link to="/logout" className={focus ? 'active' : ''}> to="/dashboard"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Dashboard
</Link>
</MenuItem>
<MenuItem>
<Link
to="/logout"
className="rounded-field block px-3 py-1.5 data-[focus]:bg-base-200"
>
Logout Logout
</Link> </Link>
)}
</MenuItem> </MenuItem>
</MenuItems> </MenuItems>
</Menu> </Menu>

View File

@@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row"> <li className="list-row">
<div> <div>
<p className="font-semibold">Login</p> <p className="font-semibold">Login</p>
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/70">
POST to <code className="kbd kbd-sm">/api/auth/login</code> with POST to <code className="kbd kbd-sm">/api/auth/login</code> with
username &amp; password username &amp; password
</p> </p>
@@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row"> <li className="list-row">
<div> <div>
<p className="font-semibold">Register</p> <p className="font-semibold">Register</p>
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/70">
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
user details user details
</p> </p>
@@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row"> <li className="list-row">
<div> <div>
<p className="font-semibold">Session</p> <p className="font-semibold">Session</p>
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/70">
JWT access &amp; refresh tokens stored in an HTTP-only cookie JWT access &amp; refresh tokens stored in an HTTP-only cookie
</p> </p>
</div> </div>
@@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row"> <li className="list-row">
<div> <div>
<p className="font-semibold">Protected Routes</p> <p className="font-semibold">Protected Routes</p>
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/70">
This dashboard requires authentication via{' '} This dashboard requires authentication via{' '}
<code className="kbd kbd-sm">requireAuth()</code> <code className="kbd kbd-sm">requireAuth()</code>
</p> </p>
@@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row"> <li className="list-row">
<div> <div>
<p className="font-semibold">Token Refresh</p> <p className="font-semibold">Token Refresh</p>
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/70">
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '} refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
header header
@@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (
<li className="list-row"> <li className="list-row">
<div> <div>
<p className="font-semibold">Account Management</p> <p className="font-semibold">Account Management</p>
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/70">
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '} PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
<code className="kbd kbd-sm">/email</code>,{' '} <code className="kbd kbd-sm">/email</code>,{' '}
<code className="kbd kbd-sm">/password</code>,{' '} <code className="kbd kbd-sm">/password</code>,{' '}

View File

@@ -14,7 +14,7 @@ const SectionCard = ({ icon, title, description, open, onToggle, children }: Sec
<div className="card-body"> <div className="card-body">
<div className="flex w-full items-center justify-between gap-5"> <div className="flex w-full items-center justify-between gap-5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="text-base-content/60 mt-1">{icon}</span> <span className="text-base-content/70 mt-1">{icon}</span>
<div> <div>
<h2 className="card-title text-lg">{title}</h2> <h2 className="card-title text-lg">{title}</h2>
<p className="text-sm text-base-content/70">{description}</p> <p className="text-sm text-base-content/70">{description}</p>

View File

@@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
</p> </p>
<div className="bg-base-200 rounded-box p-4 mt-2"> <div className="bg-base-200 rounded-box p-4 mt-2">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3"> <p className="text-xs font-semibold uppercase tracking-widest text-base-content/70 mb-3">
Session Info Session Info
</p> </p>
<div className="stats stats-vertical w-full"> <div className="stats stats-vertical w-full">

Some files were not shown because too many files have changed in this diff Show More