Compare commits

..

2 Commits

Author SHA1 Message Date
Aaron Po
cd0f33f603 Ci cd/test runner (#268) 2026-08-31 14:16:33 -04:00
Aaron Po
8b429be857 Enforce brewery ownership on update and delete (#267) 2026-08-31 13:37:17 -04:00
48 changed files with 1315 additions and 367 deletions

55
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
containerized-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Generate test environment file
run: ./generate-env.sh .env.test
- name: Build test images
run: docker compose --env-file .env.test -f docker-compose.test.yaml build
- name: Run tests
run: docker compose --env-file .env.test -f docker-compose.test.yaml up --abort-on-container-exit
- name: Check test container exit codes
if: always()
run: |
status=0
for name in 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)"
echo "$name exited with $code"
if [ "$code" != "0" ]; then
status=1
fi
done
exit $status
- name: Show service logs on failure
if: failure()
run: docker compose --env-file .env.test -f docker-compose.test.yaml logs
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: web/test-results
- name: Tear down
if: always()
run: docker compose --env-file .env.test -f docker-compose.test.yaml down -v

1
.gitignore vendored
View File

@@ -501,3 +501,4 @@ FodyWeavers.xsd
*storybook.log
storybook-static
web/test-results/frontend/vitest-storybook.xml

View File

@@ -84,7 +84,7 @@ prerequisite list, including the pipeline's C++ toolchain.
For full setup details, use [Getting Started](docs/website/getting-started.md).
This section is the shortest path to a working dev environment.
### Backend (Docker)
### Full stack (Docker)
```bash
git clone https://github.com/aaronpo97/the-biergarten-app
@@ -94,14 +94,15 @@ cp web/.env.example web/.env.dev
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up --build -d
```
Backend access:
Access:
- Website: http://localhost:3000
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI (dev SMTP): http://localhost:8025
- SeaweedFS S3 API (dev object storage): http://localhost:8333
### Frontend (Node)
### Frontend (Node, outside Docker)
```bash
cd web/frontend
@@ -140,14 +141,18 @@ archive/
## Testing
Create a test environment file, then run the backend test stack with Docker:
Create a test environment file, then run the full test stack (backend and
frontend) with Docker:
```bash
cp web/.env.example web/.env.test
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abort-on-container-exit
```
See [Testing](docs/website/testing.md) for the full command list.
The same command runs in CI via
[`.github/workflows/tests.yml`](.github/workflows/tests.yml) on every push
and pull request to `main`. See [Testing](docs/website/testing.md) for the
full command list.
## Configuration

View File

@@ -11,6 +11,7 @@ The project uses Docker Compose to orchestrate multiple services:
- Database migrations runner (DbUp)
- Database seeder
- .NET API
- React Router website (SSR frontend)
- Test runners
See the [deployment diagram](diagrams-out/deployment.svg) for visual
@@ -39,6 +40,7 @@ sqlserver # SQL Server 2022 (port 1434)
database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
frontend # React Router website (port 3000)
mailpit # Local dev SMTP server + UI (port 8025)
seaweedfs # S3-compatible object storage (ports 8333, 8888, 9333, 23646)
```
@@ -51,6 +53,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
**Access**:
- Website: http://localhost:3000
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
@@ -90,6 +93,7 @@ database.migrations # Fresh schema
database.seed # Test data
api.specs # Reqnroll BDD tests
unit.tests # All Features.*.Tests unit test projects
frontend.tests # Storybook Vitest + Playwright suites (own Playwright image)
```
**Run Tests**:
@@ -119,13 +123,16 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
- Health checks enabled
- Restart policies (unless-stopped)
- Security hardening
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core`
are reachable only from other containers on `prodnet`
**Services**:
```yaml
sqlserver # Production SQL Server
sqlserver # Production SQL Server (internal only)
database.migrations # Schema updates only
api.core # Production API
api.core # Production API (internal only, no published ports)
frontend # Production website (port 3000, the only public binding)
```
**Deploy Production**:

View File

@@ -20,7 +20,9 @@ Direct environment variable access via `Environment.GetEnvironmentVariable()`.
### Frontend (`web/frontend`)
The active website reads runtime values from the server environment for its auth
and API integration.
and API integration. In Docker, the `frontend` service is wired into the same
`web/.env.dev` / `.env.test` / `.env.prod` files as the backend; running it
locally with `npm run dev` still reads from the shell environment.
### Docker
@@ -34,8 +36,9 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`,
`SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`, `SEAWEEDFS_SECRET_ACCESS_KEY`)
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SESSION_SECRET`,
`SMTP_USERNAME`, `SMTP_PASSWORD`, `SEAWEEDFS_ACCESS_KEY_ID`,
`SEAWEEDFS_SECRET_ACCESS_KEY`)
with freshly randomized ones via `openssl`. Non-secret values are left
untouched. The output file is written with `600` permissions.
@@ -173,19 +176,26 @@ The active website does not use the old Next.js/Prisma environment model. Its
core runtime variables are:
```bash
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
API_BASE_URL=http://api.core:8080 # Base URL for the .NET API (container DNS name in Docker)
SESSION_SECRET=<generated-secret> # Cookie session signing secret
NODE_ENV=development # Standard Node runtime mode
PORT=3000 # Port the built server listens on
```
The `frontend` Docker service hardcodes `API_BASE_URL` to `http://api.core:8080`
(the backend's in-network service name) and `PORT` to `3000`; only
`SESSION_SECRET` is sourced from the env file, and `NODE_ENV` is set per
compose file (`development` in dev, `production` in prod).
### Frontend variable details
#### `API_BASE_URL`
- **Required**: Yes for local development
- **Default in code**: `http://localhost:8080`
- **Used by**: `web/frontend/app/lib/auth.server.ts`
- **Purpose**: Routes website auth actions to the .NET API
- **Used by**: `web/frontend/app/features/auth/auth.server.ts`,
`web/frontend/app/features/breweries/breweries.server.ts`
- **Purpose**: Routes website auth and API calls to the .NET API
#### `SESSION_SECRET`
@@ -200,6 +210,12 @@ NODE_ENV=development # Standard Node runtime mode
- **Typical values**: `development`, `production`, `test`
- **Purpose**: Controls secure cookie behavior and runtime mode
#### `PORT`
- **Required**: No
- **Default**: `3000`
- **Purpose**: Port the built server (`build/server/index.js`) binds to
### SMTP configuration (backend)
Read by `Infrastructure.Email/SmtpEmailProvider.cs` for sending confirmation and
@@ -312,9 +328,10 @@ reference only and is not run as part of the active stack.
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
| `NODE_ENV` | | ✓ | | No | Runtime mode |
| `API_BASE_URL` | | ✓ | ✓ | Yes | Website-to-API base URL |
| `SESSION_SECRET` | | ✓ | ✓ | Yes | Website session signing |
| `NODE_ENV` | | ✓ | ✓ | No | Runtime mode |
| `PORT` | | ✓ | ✓ | No | Frontend server port |
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
@@ -366,6 +383,9 @@ REFRESH_TOKEN_SECRET=<generated-with-openssl>
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
WEBSITE_BASE_URL=http://localhost:3000
# Frontend session signing
SESSION_SECRET=<generated-with-openssl>
# SMTP (Mailpit in dev)
SMTP_HOST=mailpit
SMTP_PORT=1025

View File

@@ -33,7 +33,8 @@ This command:
1. Starts a fresh SQL Server instance
2. Runs database migrations
3. Seeds test data
4. Executes all test suites in parallel
4. Executes all test suites in parallel, including the frontend's Storybook
Vitest and Playwright suites (`frontend.tests`, which needs no database)
5. Exports results to `./test-results/`
6. Exits when tests complete
@@ -49,6 +50,8 @@ cat test-results/Features.Users.Tests.trx
cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.Emails.Tests.trx
cat test-results/Features.PhotoUpload.Tests.trx
cat test-results/frontend/vitest-storybook.xml
cat test-results/frontend/playwright-storybook.xml
```
### Clean up
@@ -327,8 +330,10 @@ Scenario: User login with valid credentials
## Continuous integration
Tests run automatically in CI/CD pipelines using the test Docker Compose
configuration:
The `.github/workflows/tests.yml` GitHub Actions workflow runs on every push
and pull request to `main`. It runs the same test Docker Compose
configuration used locally, so backend and frontend tests execute in the same
containerized environment in CI as they do on a developer machine:
```bash
# CI/CD command
@@ -337,13 +342,22 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abo
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v
```
Because `--abort-on-container-exit` stops the stack as soon as any one-shot
test container exits (success or failure), the workflow separately inspects
the exit code of each test container (`test-env-api-specs`,
`test-env-unit-tests`, `test-env-frontend-tests`) after `up` returns and fails
the job if any of them is non-zero. `./test-results/` is uploaded as a build
artifact regardless of outcome.
Exit codes:
- `0` - All tests passed
- Non-zero - Test failures occurred
Frontend UI checks should also be included in CI for the active website
workspace:
The frontend's Storybook Vitest and Playwright suites run inside the
`frontend.tests` container (see [Docker Guide](docker.md)) as part of that
same compose run, so no separate frontend CI step is needed. To run them
locally without Docker:
```bash
cd web/frontend

View File

@@ -39,6 +39,14 @@ ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
# ======================
# Frontend (Website) Configuration
# ======================
# Cookie session signing secret for the React Router website container
# IMPORTANT: Generate a secure secret (minimum 32 characters)
# Command: openssl rand -base64 32
SESSION_SECRET=your-secure-session-secret-key
# ======================
# SMTP Configuration

View File

@@ -41,5 +41,7 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,95 @@
Feature: Brewery Management
As an authenticated user
I want to create, view, update and delete brewery posts
So that I can share information about breweries with the community
Background:
Given the API is running
Scenario: Successful brewery creation
Given I have an existing account
And I am logged in
And a city exists
When I create a brewery with values:
| BreweryName | Description | AddressLine1 | PostalCode |
| Roundhouse Brewery | A cozy local brewery | 123 Main St | 90210 |
Then the response has HTTP status 201
And the response JSON should have "breweryName" equal "Roundhouse Brewery"
Scenario: Brewery creation fails without authentication
Given a city exists
When I create a brewery with values:
| BreweryName | Description | AddressLine1 | PostalCode |
| No Auth Brew | A brewery | 1 Nowhere St | 00000 |
Then the response has HTTP status 401
Scenario: Brewery creation fails with a non-existent city
Given I have an existing account
And I am logged in
When I create a brewery with a non-existent city
Then the response has HTTP status 404
Scenario: Retrieve an existing brewery by ID
Given I have an existing account
And I am logged in
And a city exists
And I have created a brewery
When I retrieve the brewery by ID
Then the response has HTTP status 200
And the response JSON should have "breweryName" equal "Test Brewery"
Scenario: Retrieving a non-existent brewery returns not found
When I retrieve a brewery by a non-existent ID
Then the response has HTTP status 404
Scenario: Owner can update their brewery
Given I have an existing account
And I am logged in
And a city exists
And I have created a brewery
When I update the brewery with values:
| BreweryName | Description |
| Renamed Brewery | An updated description |
Then the response has HTTP status 200
And the response JSON should have "breweryName" equal "Renamed Brewery"
Scenario: A different user cannot update someone else's brewery
Given I have an existing account
And I am logged in
And a city exists
And I have created a brewery
And I am logged in as a different user
When I update the brewery with values:
| BreweryName | Description |
| Hijacked | Nope |
Then the response has HTTP status 403
Scenario: Updating a non-existent brewery returns not found
Given I have an existing account
And I am logged in
When I update a non-existent brewery
Then the response has HTTP status 404
Scenario: Owner can delete their brewery
Given I have an existing account
And I am logged in
And a city exists
And I have created a brewery
When I delete the brewery
Then the response has HTTP status 200
And retrieving the brewery by ID should now return HTTP status 404
Scenario: A different user cannot delete someone else's brewery
Given I have an existing account
And I am logged in
And a city exists
And I have created a brewery
And I am logged in as a different user
When I delete the brewery
Then the response has HTTP status 403
Scenario: Deleting a non-existent brewery returns not found
Given I have an existing account
And I am logged in
When I delete a non-existent brewery
Then the response has HTTP status 404

View File

@@ -0,0 +1,39 @@
using Reqnroll;
namespace API.Specs.Steps;
/// <summary>
/// Shared <see cref="TestApiFactory" />/<see cref="HttpClient" /> plumbing and the <see cref="ScenarioContext" />
/// keys used to coordinate it, common to every step definition class that drives the test API.
/// </summary>
public abstract class ApiStepsBase(ScenarioContext scenario)
{
protected ScenarioContext Scenario { get; } = scenario;
protected const string ClientKey = "client";
protected const string FactoryKey = "factory";
protected const string ResponseKey = "response";
protected const string ResponseBodyKey = "responseBody";
protected TestApiFactory GetFactory()
{
TestApiFactory? factory = Scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out TestApiFactory? f
)
? f
: new TestApiFactory();
Scenario[FactoryKey] = factory;
return factory;
}
protected HttpClient GetClient()
{
if (Scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
client = GetFactory().CreateClient();
Scenario[ClientKey] = client;
return client;
}
}

View File

@@ -10,40 +10,14 @@ using Reqnroll;
namespace API.Specs.Steps;
[Binding]
public class AuthSteps(ScenarioContext scenario)
public class AuthSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
{
private const string ClientKey = "client";
private const string FactoryKey = "factory";
private const string ResponseKey = "response";
private const string ResponseBodyKey = "responseBody";
private const string TestUserKey = "testUser";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
private const string PreviousAccessTokenKey = "previousAccessToken";
private const string PreviousRefreshTokenKey = "previousRefreshToken";
private TestApiFactory GetFactory()
{
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out TestApiFactory? f
)
? f
: new TestApiFactory();
scenario[FactoryKey] = factory;
return factory;
}
private HttpClient GetClient()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
client = GetFactory().CreateClient();
scenario[ClientKey] = client;
return client;
}
/// <summary>
/// Reads a required configuration value from the running <see cref="TestApiFactory" />'s own
/// <see cref="IConfiguration" />, so forged tokens are always signed with the exact secret the
@@ -107,7 +81,7 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have an existing account")]
public async Task GivenIHaveAnExistingAccount()
{
scenario[TestUserKey] = (FixtureUsername, FixturePassword);
Scenario[TestUserKey] = (FixtureUsername, FixturePassword);
HttpClient client = GetClient();
var registrationData = new
@@ -153,14 +127,14 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I do not have an existing account")]
public void GivenIDoNotHaveAnExistingAccount()
{
scenario[TestUserKey] = ("Failing", "User");
Scenario[TestUserKey] = ("Failing", "User");
}
[When("I submit a login request with a username and password")]
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
{
HttpClient client = GetClient();
(string username, string password) = scenario.TryGetValue(TestUserKey, out (string username, string password) user)
(string username, string password) = Scenario.TryGetValue(TestUserKey, out (string username, string password) user)
? user
: ("test.user", "password");
@@ -174,8 +148,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with a missing username")]
@@ -192,8 +166,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with a missing password")]
@@ -210,8 +184,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a login request with both username and password missing")]
@@ -226,18 +200,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[Then("the response JSON should have an access token")]
public void ThenTheResponseJsonShouldHaveAnAccessToken()
{
scenario
Scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
@@ -270,8 +244,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a registration request with values:")]
@@ -321,8 +295,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a registration request using a GET request")]
@@ -337,8 +311,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[Given("I have registered a new account")]
@@ -364,13 +338,13 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement root = doc.RootElement;
scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
Scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
}
[Given("I am logged in")]
@@ -396,12 +370,12 @@ public class AuthSteps(ScenarioContext scenario)
payloadElem.TryGetProperty("accessToken", out JsonElement tokenElem)
|| payloadElem.TryGetProperty("AccessToken", out tokenElem)
)
scenario["accessToken"] = tokenElem.GetString();
Scenario["accessToken"] = tokenElem.GetString();
if (
payloadElem.TryGetProperty("refreshToken", out JsonElement refreshElem)
|| payloadElem.TryGetProperty("RefreshToken", out refreshElem)
)
scenario["refreshToken"] = refreshElem.GetString();
Scenario["refreshToken"] = refreshElem.GetString();
}
}
@@ -421,15 +395,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a valid access token for my account")]
public void GivenIHaveAValidAccessTokenForMyAccount()
{
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.AccessTokenSecret);
scenario["accessToken"] = GenerateJwtToken(
Scenario["accessToken"] = GenerateJwtToken(
userId,
username,
secret,
@@ -440,15 +414,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount()
{
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
scenario["confirmationToken"] = GenerateJwtToken(
Scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
secret,
@@ -459,15 +433,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have an expired confirmation token for my account")]
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
{
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
scenario["confirmationToken"] = GenerateJwtToken(
Scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
secret,
@@ -478,15 +452,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a confirmation token signed with the wrong secret")]
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
{
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
string? username = scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? user)
? user
: throw new InvalidOperationException("registered username not found in scenario");
const string wrongSecret = "wrong-confirmation-secret-that-is-very-long-1234567890";
scenario["confirmationToken"] = GenerateJwtToken(
Scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
wrongSecret,
@@ -498,7 +472,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("accessToken", out string? t)
string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
? t
: "invalid-token";
@@ -509,8 +483,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a request to a protected endpoint with an invalid access token")]
@@ -524,18 +498,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: "valid-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -548,18 +522,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit the same confirmation request again")]
public async Task WhenISubmitTheSameConfirmationRequestAgain()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: "valid-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -572,8 +546,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with a malformed token")]
@@ -581,7 +555,7 @@ public class AuthSteps(ScenarioContext scenario)
{
HttpClient client = GetClient();
const string token = "malformed-token-not-jwt";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -594,20 +568,20 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with a valid refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
{
HttpClient client = GetClient();
if (scenario.TryGetValue<string>("accessToken", out string? oldAccessToken))
scenario[PreviousAccessTokenKey] = oldAccessToken;
if (scenario.TryGetValue<string>("refreshToken", out string? oldRefreshToken))
scenario[PreviousRefreshTokenKey] = oldRefreshToken;
if (Scenario.TryGetValue<string>("accessToken", out string? oldAccessToken))
Scenario[PreviousAccessTokenKey] = oldAccessToken;
if (Scenario.TryGetValue<string>("refreshToken", out string? oldRefreshToken))
Scenario[PreviousRefreshTokenKey] = oldRefreshToken;
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
string? token = Scenario.TryGetValue<string>("refreshToken", out string? t)
? t
: "valid-refresh-token";
@@ -616,8 +590,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with an invalid refresh token")]
@@ -630,8 +604,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with the expired refresh token")]
@@ -644,8 +618,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request with a missing refresh token")]
@@ -657,8 +631,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a refresh token request using a GET request")]
@@ -672,8 +646,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
// Protected Endpoint Steps
@@ -685,22 +659,22 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[Given("I am logged in with an immediately-expiring access token")]
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
{
// Simulate an expired access token for auth rejection behavior.
scenario["accessToken"] = "expired-access-token";
Scenario["accessToken"] = "expired-access-token";
return Task.CompletedTask;
}
[Given("I have an access token signed with the wrong secret")]
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
{
scenario["accessToken"] =
Scenario["accessToken"] =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
}
@@ -708,7 +682,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("accessToken", out string? t)
string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
? t
: "expired-token";
@@ -719,15 +693,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a request to a protected endpoint with the tampered token")]
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("accessToken", out string? t)
string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
? t
: "tampered-token";
@@ -738,8 +712,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When(
@@ -748,7 +722,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
string? token = Scenario.TryGetValue<string>("refreshToken", out string? t)
? t
: "refresh-token";
@@ -759,24 +733,24 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[Given("I have a valid confirmation token")]
public void GivenIHaveAValidConfirmationToken()
{
scenario["confirmationToken"] = "valid-confirmation-token";
Scenario["confirmationToken"] = "valid-confirmation-token";
}
[When("I submit a confirmation request with the expired token")]
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: "expired-confirmation-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -789,18 +763,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the tampered token")]
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: "tampered-confirmation-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -813,15 +787,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with a missing token")]
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{
HttpClient client = GetClient();
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -831,15 +805,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request using an invalid HTTP method")]
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: "valid-confirmation-token";
@@ -850,8 +824,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When(
@@ -860,7 +834,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: "confirmation-token";
@@ -871,8 +845,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with an invalid token")]
@@ -880,7 +854,7 @@ public class AuthSteps(ScenarioContext scenario)
{
HttpClient client = GetClient();
const string token = "invalid-confirmation-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -893,15 +867,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a confirmation request with the valid token without an access token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: "valid-token";
@@ -912,14 +886,14 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[Then("the response JSON should have a new access token")]
public void ThenTheResponseJsonShouldHaveANewAccessToken()
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload");
@@ -927,14 +901,14 @@ public class AuthSteps(ScenarioContext scenario)
accessToken.Should().NotBeNullOrWhiteSpace();
if (scenario.TryGetValue<string>(PreviousAccessTokenKey, out string? previousAccessToken))
if (Scenario.TryGetValue<string>(PreviousAccessTokenKey, out string? previousAccessToken))
accessToken.Should().NotBe(previousAccessToken);
}
[Then("the response JSON should have a new refresh token")]
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
{
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload");
@@ -942,7 +916,7 @@ public class AuthSteps(ScenarioContext scenario)
refreshToken.Should().NotBeNullOrWhiteSpace();
if (scenario.TryGetValue<string>(PreviousRefreshTokenKey, out string? previousRefreshToken))
if (Scenario.TryGetValue<string>(PreviousRefreshTokenKey, out string? previousRefreshToken))
refreshToken.Should().NotBe(previousRefreshToken);
}
@@ -950,10 +924,10 @@ public class AuthSteps(ScenarioContext scenario)
public async Task GivenIHaveConfirmedMyAccount()
{
HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t)
string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t
: throw new InvalidOperationException("confirmation token not found");
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -972,10 +946,10 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
{
HttpClient client = GetClient();
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found");
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -988,8 +962,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request for a non-existent user")]
@@ -997,7 +971,7 @@ public class AuthSteps(ScenarioContext scenario)
{
HttpClient client = GetClient();
Guid fakeUserId = Guid.NewGuid();
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at)
string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at
: string.Empty;
@@ -1010,15 +984,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
[When("I submit a resend confirmation request without an access token")]
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
{
HttpClient client = GetClient();
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : Guid.NewGuid();
Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id) ? id : Guid.NewGuid();
HttpRequestMessage requestMessage = new(
HttpMethod.Post,
@@ -1027,7 +1001,7 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody;
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
}

View File

@@ -0,0 +1,329 @@
using System.Text;
using System.Text.Json;
using Database.Connection;
using Features.Locations.Dtos;
using Features.Locations.Repository;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll;
namespace API.Specs.Steps;
[Binding]
public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
{
private const string AccessTokenKey = "accessToken";
private const string CityIdKey = "cityId";
private const string BreweryPostIdKey = "breweryPostId";
private const string BreweryRowVersionKey = "breweryRowVersion";
private const string FixtureCountryName = "Fixture Country";
private const string FixtureCountryIsoCode = "FX";
private const string FixtureStateProvinceName = "Fixture State";
private const string FixtureStateProvinceIsoCode = "FX-TS";
private const string FixtureCityName = "Fixture City";
// GetOrCreateCityIdAsync is not fully race-safe under concurrent callers creating the same
// city (see its remarks); scenarios run in parallel across feature classes, so provisioning
// the shared fixture city is serialized the same way AuthSteps serializes its fixture account.
private static readonly SemaphoreSlim CityProvisioningLock = new(1, 1);
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 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;
}
[Given("a city exists")]
public async Task GivenACityExists()
{
ISqlConnectionFactory connectionFactory = GetFactory()
.Services.GetRequiredService<ISqlConnectionFactory>();
LocationRepository repository = new(connectionFactory);
CityLocation fixtureCity = new(
FixtureCityName,
FixtureStateProvinceName,
FixtureStateProvinceIsoCode,
FixtureCountryName,
FixtureCountryIsoCode
);
await CityProvisioningLock.WaitAsync();
Guid cityId;
try
{
cityId = await repository.GetOrCreateCityIdAsync(fixtureCity);
}
finally
{
CityProvisioningLock.Release();
}
Scenario[CityIdKey] = cityId;
}
private async Task CreateBreweryAsync(
string breweryName,
string description,
string addressLine1,
string postalCode,
Guid cityId
)
{
var createData = new
{
breweryName,
description,
location = new
{
cityId,
addressLine1,
addressLine2 = (string?)null,
postalCode,
coordinates = (object?)null,
},
};
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Post,
"/api/brewery"
);
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(createData),
Encoding.UTF8,
"application/json"
);
await SendAsync(requestMessage);
}
[When("I create a brewery with values:")]
public async Task WhenICreateABreweryWithValues(Table table)
{
DataTableRow row = table.Rows[0];
Guid cityId = Scenario.TryGetValue(CityIdKey, out Guid id) ? id : Guid.NewGuid();
await CreateBreweryAsync(
row["BreweryName"] ?? "",
row["Description"] ?? "",
row["AddressLine1"] ?? "",
row["PostalCode"] ?? "",
cityId
);
}
[When("I create a brewery with a non-existent city")]
public async Task WhenICreateABreweryWithANonExistentCity()
{
await CreateBreweryAsync(
"Ghost City Brewery",
"A brewery in a city that does not exist",
"1 Nowhere St",
"00000",
Guid.NewGuid()
);
}
[Given("I have created a brewery")]
public async Task GivenIHaveCreatedABrewery()
{
Guid cityId = Scenario.TryGetValue(CityIdKey, out Guid id)
? id
: throw new InvalidOperationException("city ID not found in scenario");
await CreateBreweryAsync(
"Test Brewery",
"A test brewery",
"123 Test St",
"00000",
cityId
);
Scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
((int)response!.StatusCode).Should().Be(201, "fixture brewery creation must succeed");
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload");
Scenario[BreweryPostIdKey] = payload.GetProperty("breweryPostId").GetGuid();
Scenario[BreweryRowVersionKey] = payload.GetProperty("rowVersion").GetString()
?? throw new InvalidOperationException("rowVersion missing from created brewery");
}
private Guid GetCreatedBreweryPostId()
{
return Scenario.TryGetValue(BreweryPostIdKey, out Guid id)
? id
: throw new InvalidOperationException("brewery post ID not found in scenario");
}
[When("I retrieve the brewery by ID")]
public async Task WhenIRetrieveTheBreweryById()
{
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Get,
$"/api/brewery/{GetCreatedBreweryPostId()}"
);
await SendAsync(requestMessage);
}
[When("I retrieve a brewery by a non-existent ID")]
public async Task WhenIRetrieveABreweryByANonExistentId()
{
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Get,
$"/api/brewery/{Guid.NewGuid()}"
);
await SendAsync(requestMessage);
}
[When("I update the brewery with values:")]
public async Task WhenIUpdateTheBreweryWithValues(Table table)
{
DataTableRow row = table.Rows[0];
Guid breweryPostId = GetCreatedBreweryPostId();
string rowVersion = Scenario.TryGetValue<string>(BreweryRowVersionKey, out string? rv)
? rv
: throw new InvalidOperationException("row version not found in scenario");
var updateData = new
{
breweryPostId,
rowVersion,
breweryName = row["BreweryName"] ?? "",
description = row["Description"] ?? "",
location = (object?)null,
};
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Put,
$"/api/brewery/{breweryPostId}"
);
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(updateData),
Encoding.UTF8,
"application/json"
);
await SendAsync(requestMessage);
}
[When("I update a non-existent brewery")]
public async Task WhenIUpdateANonExistentBrewery()
{
Guid breweryPostId = Guid.NewGuid();
var updateData = new
{
breweryPostId,
rowVersion = Convert.ToBase64String([0x00, 0x00, 0x00, 0x00]),
breweryName = "Nonexistent",
description = "Nonexistent",
location = (object?)null,
};
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Put,
$"/api/brewery/{breweryPostId}"
);
requestMessage.Content = new StringContent(
JsonSerializer.Serialize(updateData),
Encoding.UTF8,
"application/json"
);
await SendAsync(requestMessage);
}
[When("I delete the brewery")]
public async Task WhenIDeleteTheBrewery()
{
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Delete,
$"/api/brewery/{GetCreatedBreweryPostId()}"
);
await SendAsync(requestMessage);
}
[When("I delete a non-existent brewery")]
public async Task WhenIDeleteANonExistentBrewery()
{
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Delete,
$"/api/brewery/{Guid.NewGuid()}"
);
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}")]
public async Task ThenRetrievingTheBreweryByIdShouldNowReturnHttpStatus(int expectedCode)
{
HttpRequestMessage requestMessage = NewAuthenticatedRequest(
HttpMethod.Get,
$"/api/brewery/{GetCreatedBreweryPostId()}"
);
await SendAsync(requestMessage);
Scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should()
.BeTrue();
((int)response!.StatusCode).Should().Be(expectedCode);
}
}

View File

@@ -6,8 +6,8 @@ public sealed class BreweryPost
public Guid BreweryPostId { get; set; }
// FK References
// Note: An empty GUID signifies that this object is incomplete
public Guid PostedById { get; set; }
public string PostedBy { get; set; } = string.Empty;
// Attributes
public string BreweryName { get; set; } = string.Empty;
@@ -21,6 +21,7 @@ public sealed class BreweryPost
// Navigation Properties
public BreweryPostLocation? Location { get; set; }
public List<BeerPost> BeerPosts { get; set; } = [];
public string PostedBy { get; set; } = string.Empty;
// Query-computed
public DistanceInformation? Distance { get; set; }

View File

@@ -32,6 +32,9 @@ public class CreateBreweryHandlerTests
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask);
_repoMock
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => persisted);
DateTime before = DateTime.UtcNow;
BreweryDto result = await _handler.Handle(command, CancellationToken.None);
@@ -53,8 +56,27 @@ public class CreateBreweryHandlerTests
persisted.Location.PostalCode.Should().Be(command.Location.PostalCode);
_repoMock.Verify(r => r.CreateAsync(It.IsAny<BreweryPost>()), Times.Once);
_repoMock.Verify(
r => r.GetByIdAsync(persisted.BreweryPostId, It.IsAny<CancellationToken>()),
Times.Once
);
result.BreweryName.Should().Be("MyBrew");
result.Location.Should().NotBeNull();
}
[Fact]
public async Task Handle_ThrowsInvalidOperationException_WhenBreweryNotFoundAfterCreation()
{
CreateBreweryCommand command = new(Guid.NewGuid(), "MyBrew", "Desc", ValidLocation());
_repoMock.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>())).Returns(Task.CompletedTask);
_repoMock
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((BreweryPost?)null);
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
}

View File

@@ -1,21 +1,66 @@
using Domain.Exceptions;
using Features.Breweries.Commands.DeleteBrewery;
using Features.Breweries.Repository;
using FluentAssertions;
using Moq;
namespace Features.Breweries.Tests.Commands;
public class DeleteBreweryHandlerTests
{
[Fact]
public async Task Handle_DelegatesToRepository()
private readonly DeleteBreweryHandler _handler;
private readonly Mock<IBreweryRepository> _repoMock = new();
public DeleteBreweryHandlerTests()
{
Mock<IBreweryRepository> repoMock = new();
DeleteBreweryHandler handler = new(repoMock.Object);
Guid id = Guid.NewGuid();
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
_handler = new DeleteBreweryHandler(_repoMock.Object);
}
await handler.Handle(new DeleteBreweryCommand(id), CancellationToken.None);
private void SetUpExistingBrewery(Guid breweryPostId, Guid postedById)
{
_repoMock
.Setup(r => r.GetPostedByIdAsync(breweryPostId, It.IsAny<CancellationToken>()))
.ReturnsAsync(postedById);
}
repoMock.Verify(r => r.DeleteAsync(id), Times.Once);
[Fact]
public async Task Handle_DelegatesToRepository_WhenRequestingUserIsThePoster()
{
Guid breweryPostId = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, requestingUserId);
_repoMock.Setup(r => r.DeleteAsync(breweryPostId)).Returns(Task.CompletedTask);
await _handler.Handle(
new DeleteBreweryCommand(breweryPostId, requestingUserId),
CancellationToken.None
);
_repoMock.Verify(r => r.DeleteAsync(breweryPostId), Times.Once);
}
[Fact]
public async Task Handle_ThrowsNotFoundException_WhenBreweryDoesNotExist()
{
DeleteBreweryCommand command = new(Guid.NewGuid(), Guid.NewGuid());
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
_repoMock.Verify(r => r.DeleteAsync(It.IsAny<Guid>()), Times.Never);
}
[Fact]
public async Task Handle_ThrowsForbiddenException_WhenRequestingUserIsNotThePoster()
{
Guid breweryPostId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, Guid.NewGuid());
DeleteBreweryCommand command = new(breweryPostId, Guid.NewGuid());
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
_repoMock.Verify(r => r.DeleteAsync(It.IsAny<Guid>()), Times.Never);
}
}

View File

@@ -1,4 +1,5 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Breweries.Commands.UpdateBrewery;
using Features.Breweries.Repository;
using FluentAssertions;
@@ -16,13 +17,24 @@ public class UpdateBreweryHandlerTests
_handler = new UpdateBreweryHandler(_repoMock.Object);
}
private void SetUpExistingBrewery(Guid breweryPostId, Guid postedById)
{
_repoMock
.Setup(r => r.GetPostedByIdAsync(breweryPostId, It.IsAny<CancellationToken>()))
.ReturnsAsync(postedById);
}
[Fact]
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
{
Guid breweryPostId = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, requestingUserId);
UpdateBreweryCommand command = new(
Guid.NewGuid(),
breweryPostId,
requestingUserId,
[0x01, 0x02],
Guid.NewGuid(),
"Renamed",
"New description",
null
@@ -30,8 +42,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null;
_repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
.Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
.ReturnsAsync(() => persisted!);
DateTime before = DateTime.UtcNow;
@@ -40,7 +52,6 @@ public class UpdateBreweryHandlerTests
persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
persisted.PostedById.Should().Be(command.PostedById);
persisted.BreweryName.Should().Be("Renamed");
persisted.Description.Should().Be("New description");
persisted.RowVersion.Should().Equal(command.RowVersion);
@@ -51,10 +62,14 @@ public class UpdateBreweryHandlerTests
[Fact]
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
{
Guid breweryPostId = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, requestingUserId);
UpdateBreweryCommand command = new(
Guid.NewGuid(),
breweryPostId,
requestingUserId,
[0x01, 0x02],
Guid.NewGuid(),
"Name",
"Description",
null
@@ -62,8 +77,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null;
_repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
.Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
.ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None);
@@ -74,6 +89,10 @@ public class UpdateBreweryHandlerTests
[Fact]
public async Task Handle_SetsLocation_WhenCommandLocationProvided()
{
Guid breweryPostId = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, requestingUserId);
UpdateBreweryLocation locationCommand = new(
Guid.NewGuid(),
Guid.NewGuid(),
@@ -83,9 +102,9 @@ public class UpdateBreweryHandlerTests
null
);
UpdateBreweryCommand command = new(
Guid.NewGuid(),
breweryPostId,
requestingUserId,
[0x01, 0x02],
Guid.NewGuid(),
"Name",
"Description",
locationCommand
@@ -93,8 +112,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null;
_repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b)
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
.Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
.ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None);
@@ -109,4 +128,41 @@ public class UpdateBreweryHandlerTests
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode);
}
[Fact]
public async Task Handle_ThrowsNotFoundException_WhenBreweryDoesNotExist()
{
UpdateBreweryCommand command = new(
Guid.NewGuid(),
Guid.NewGuid(),
[0x01, 0x02],
"Name",
"Description",
null
);
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task Handle_ThrowsForbiddenException_WhenRequestingUserIsNotThePoster()
{
Guid breweryPostId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, Guid.NewGuid());
UpdateBreweryCommand command = new(
breweryPostId,
Guid.NewGuid(),
[0x01, 0x02],
"Name",
"Description",
null
);
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
}
}

View File

@@ -22,7 +22,7 @@ public class GetAllBreweryLocationsHandlerTests
{
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty<BreweryPost>());
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
new GetAllBreweryLocationsQuery(),
CancellationToken.None
);
@@ -41,7 +41,7 @@ public class GetAllBreweryLocationsHandlerTests
];
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
new GetAllBreweryLocationsQuery(),
CancellationToken.None
);

View File

@@ -29,7 +29,7 @@ public class GetBreweryLocationsWithinRangeHandlerTests
)
.ReturnsAsync(Array.Empty<BreweryPost>());
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
CancellationToken.None
);
@@ -57,7 +57,7 @@ public class GetBreweryLocationsWithinRangeHandlerTests
.Setup(r => r.GetAllLocationsWithinRange(It.IsAny<CoordinateData>(), It.IsAny<double>()))
.ReturnsAsync(breweries);
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
CancellationToken.None
);

View File

@@ -23,3 +23,10 @@ public record CreateBreweryCommand(
string Description,
CreateBreweryLocation Location
) : IRequest<BreweryDto>;
/// <summary>Request body for <c>POST /api/brewery</c>. <c>PostedById</c> comes from the caller's JWT.</summary>
public record CreateBreweryRequest(
string BreweryName,
string Description,
CreateBreweryLocation Location
);

View File

@@ -37,6 +37,17 @@ public class CreateBreweryHandler(IBreweryRepository repository)
};
await repository.CreateAsync(entity);
return entity.ToDto();
// RowVersion is a DB-computed column, unset on the in-memory entity; re-fetch so callers
// get back the value needed for a subsequent optimistic-concurrency update. Uses
// CancellationToken.None since the write already committed: cancelling the caller's request
// at this point must not turn a successful creation into a thrown exception.
BreweryPost created =
await repository.GetByIdAsync(entity.BreweryPostId, CancellationToken.None)
?? throw new InvalidOperationException(
$"Brewery '{entity.BreweryPostId}' was not found after a successful creation."
);
return created.ToDto();
}
}

View File

@@ -2,5 +2,5 @@ using MediatR;
namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Deletes a brewery post. Bound directly from the route of <c>DELETE /api/brewery/{id}</c>.</summary>
public record DeleteBreweryCommand(Guid BreweryPostId) : IRequest;
/// <summary>Deletes a brewery post. <see cref="BreweryPostId" /> is bound from the route of <c>DELETE /api/brewery/{id}</c>.</summary>
public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest;

View File

@@ -10,8 +10,17 @@ public class DeleteBreweryHandler(IBreweryRepository repository)
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>.
/// </exception>
public Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
/// <exception cref="Domain.Exceptions.ForbiddenException">
/// Thrown when <paramref name="request" />'s <c>RequestingUserId</c> did not post the brewery.
/// </exception>
public async Task Handle(DeleteBreweryCommand request, CancellationToken cancellationToken)
{
return repository.DeleteAsync(request.BreweryPostId);
await repository.EnsureCallerOwnsBreweryAsync(
request.BreweryPostId,
request.RequestingUserId,
cancellationToken
);
await repository.DeleteAsync(request.BreweryPostId);
}
}

View File

@@ -19,16 +19,25 @@ public record UpdateBreweryLocation(
);
/// <summary>
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>.
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="RowVersion" /> must be the
/// row-version last read for this brewery (e.g. from a prior <c>GET</c>); the update is rejected with a
/// <c>409 Conflict</c> if the brewery was modified since then.
/// Updates an existing brewery post. A <c>null</c> <see cref="Location" /> clears the brewery's location.
/// <see cref="RowVersion" /> must be the row-version last read for this brewery (e.g. from a prior
/// <c>GET</c>); the update is rejected with a <c>409 Conflict</c> if the brewery was modified since then.
/// </summary>
public record UpdateBreweryCommand(
Guid BreweryPostId,
Guid RequestingUserId,
byte[] RowVersion,
Guid PostedById,
string BreweryName,
string Description,
UpdateBreweryLocation? Location
) : IRequest<BreweryDto>;
/// <summary>Request body for <c>PUT /api/brewery/{id}</c>.</summary>
public record UpdateBreweryRequest(
Guid BreweryPostId,
byte[] RowVersion,
string BreweryName,
string Description,
UpdateBreweryLocation? Location
);

View File

@@ -26,13 +26,18 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
CancellationToken cancellationToken
)
{
await repository.EnsureCallerOwnsBreweryAsync(
request.BreweryPostId,
request.RequestingUserId,
cancellationToken
);
BreweryPost entity = new()
{
BreweryPostId = request.BreweryPostId,
PostedById = request.PostedById,
RowVersion = request.RowVersion,
BreweryName = request.BreweryName,
Description = request.Description,
RowVersion = request.RowVersion,
UpdatedAt = DateTime.UtcNow,
Location = request.Location is null
? null
@@ -48,7 +53,7 @@ public class UpdateBreweryHandler(IBreweryRepository repository)
},
};
BreweryPost updated = await repository.UpdateAsync(entity);
BreweryPost updated = await repository.UpdateAsync(entity, cancellationToken);
return updated.ToDto();
}
}

View File

@@ -70,13 +70,13 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <remarks>Allows anonymous access.</remarks>
[AllowAnonymous]
[HttpGet("locations")]
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetAllLocations()
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetAllLocations()
{
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send(
new GetAllBreweryLocationsQuery()
);
return Ok(
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
new ResponseBody<IEnumerable<BreweryWithLocationDto>>
{
Message = "Brewery locations retrieved successfully.",
Payload = breweries,
@@ -91,17 +91,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <remarks>Allows anonymous access.</remarks>
[AllowAnonymous]
[HttpGet("locations/nearby")]
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetLocationsWithinRange(
public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetLocationsWithinRange(
[FromQuery] double latitude,
[FromQuery] double longitude,
[FromQuery] double rangeInMetres
)
{
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send(
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
);
return Ok(
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
new ResponseBody<IEnumerable<BreweryWithLocationDto>>
{
Message = "Brewery locations retrieved successfully.",
Payload = breweries,
@@ -113,10 +113,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
[HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create(
[FromBody] CreateBreweryCommand command
[FromBody] CreateBreweryRequest request
)
{
BreweryDto brewery = await mediator.Send(command);
BreweryDto brewery = await mediator.Send(
new CreateBreweryCommand(
User.GetAuthenticatedUserId(),
request.BreweryName,
request.Description,
request.Location
)
);
return Created(
$"/api/brewery/{brewery.BreweryPostId}",
new ResponseBody<BreweryDto>
@@ -128,27 +135,37 @@ public class BreweryController(IMediator mediator) : ControllerBase
}
/// <summary>Updates an existing brewery post.</summary>
/// <param name="id">Must match <paramref name="command" />'s <c>BreweryPostId</c>.</param>
/// <param name="id">Must match <paramref name="request" />'s <c>BreweryPostId</c>.</param>
/// <returns>
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the
/// payload ID; <c>404 Not Found</c> if the brewery or its <c>CityId</c> does not exist; or
/// <c>409 Conflict</c> if the brewery was modified since <c>command.RowVersion</c> was read.
/// <c>409 Conflict</c> if the brewery was modified since <c>request.RowVersion</c> was read.
/// </returns>
[HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id,
[FromBody] UpdateBreweryCommand command
[FromBody] UpdateBreweryRequest request
)
{
if (command.BreweryPostId != id)
if (request.BreweryPostId != id)
return BadRequest(new ResponseBody { Message = "Route ID does not match payload ID." });
BreweryDto brewery = await mediator.Send(command);
BreweryDto breweryUpdated = await mediator.Send(
new UpdateBreweryCommand(
request.BreweryPostId,
User.GetAuthenticatedUserId(),
request.RowVersion,
request.BreweryName,
request.Description,
request.Location
)
);
return Ok(
new ResponseBody<BreweryDto>
{
Message = "Brewery updated successfully.",
Payload = brewery,
Payload = breweryUpdated,
}
);
}
@@ -158,7 +175,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
[HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id)
{
await mediator.Send(new DeleteBreweryCommand(id));
await mediator.Send(new DeleteBreweryCommand(id, User.GetAuthenticatedUserId()));
return Ok(new ResponseBody { Message = "Brewery deleted successfully." });
}
}

View File

@@ -36,7 +36,7 @@ public record BreweryDto(
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
/// </param>
public record SimplifiedBreweryDto(
public record BreweryWithLocationDto(
Guid BreweryPostId,
string BreweryName,
BreweryLocationDto? Location,

View File

@@ -45,9 +45,9 @@ public static class BreweryDtoMapper
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for
/// location-based queries that only need identifying and location details.
/// </summary>
public static SimplifiedBreweryDto ToSimplifiedDto(this BreweryPost brewery)
public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery)
{
return new SimplifiedBreweryDto(
return new BreweryWithLocationDto(
BreweryPostId: brewery.BreweryPostId,
BreweryName: brewery.BreweryName,
Location: brewery.Location is null

View File

@@ -7,10 +7,10 @@ namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary>
public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<SimplifiedBreweryDto>>
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<BreweryWithLocationDto>>
{
/// <summary>Retrieves every brewery post that has a set location.</summary>
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
GetAllBreweryLocationsQuery request,
CancellationToken cancellationToken
)

View File

@@ -7,4 +7,4 @@ namespace Features.Breweries.Queries.GetAllBreweryLocations;
/// Retrieves all brewery posts that have a set location. Bound directly from
/// <c>GET /api/brewery/locations</c>.
/// </summary>
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<SimplifiedBreweryDto>>;
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<BreweryWithLocationDto>>;

View File

@@ -15,7 +15,7 @@ public class GetBreweryByIdHandler(IBreweryRepository repository)
CancellationToken cancellationToken
)
{
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId);
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken);
return brewery?.ToDto();
}
}

View File

@@ -9,10 +9,10 @@ namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
/// </summary>
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<SimplifiedBreweryDto>>
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<BreweryWithLocationDto>>
{
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary>
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
public async Task<IEnumerable<BreweryWithLocationDto>> Handle(
GetBreweryLocationsWithinRangeQuery request,
CancellationToken cancellationToken
)

View File

@@ -12,4 +12,4 @@ namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// <param name="Longitude">The origin longitude, in decimal degrees.</param>
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres)
: IRequest<IEnumerable<SimplifiedBreweryDto>>;
: IRequest<IEnumerable<BreweryWithLocationDto>>;

View File

@@ -0,0 +1,24 @@
using Domain.Exceptions;
namespace Features.Breweries.Repository;
/// <summary>Shared ownership check for commands that mutate a specific brewery post.</summary>
public static class BreweryOwnershipExtensions
{
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given ID.</exception>
/// <exception cref="Domain.Exceptions.ForbiddenException">Thrown when <paramref name="requestingUserId" /> did not post the brewery.</exception>
public static async Task EnsureCallerOwnsBreweryAsync(
this IBreweryRepository repository,
Guid breweryPostId,
Guid requestingUserId,
CancellationToken cancellationToken
)
{
Guid postedById =
await repository.GetPostedByIdAsync(breweryPostId, cancellationToken)
?? throw new NotFoundException($"Brewery with ID {breweryPostId} not found.");
if (postedById != requestingUserId)
throw new ForbiddenException("You are not authorized to modify this brewery.");
}
}

View File

@@ -28,7 +28,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
/// <inheritdoc/>
public async Task<BreweryPost?> GetByIdAsync(Guid id)
public async Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
IEnumerable<BreweryPost> results = await connection.QueryAsync<
@@ -39,6 +39,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Country,
BreweryPost
>(
new CommandDefinition(
"""
SELECT
bp.BreweryPostID,
@@ -68,14 +69,29 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
WHERE bp.BreweryPostID = @BreweryPostId
""",
MapBreweryRow,
new { BreweryPostId = id },
cancellationToken: cancellationToken
),
MapBreweryRow,
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
);
return results.SingleOrDefault();
}
/// <inheritdoc/>
public async Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
return await connection.ExecuteScalarAsync<Guid?>(
new CommandDefinition(
"SELECT PostedByID FROM dbo.BreweryPost WHERE BreweryPostID = @BreweryPostId",
new { BreweryPostId = id },
cancellationToken: cancellationToken
)
);
}
/// <inheritdoc/>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{
@@ -221,10 +237,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
}
/// <inheritdoc/>
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default)
{
await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync();
await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
try
{
@@ -238,7 +254,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
transaction,
cancellationToken: cancellationToken
)
)
is not null;
@@ -253,7 +270,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new CommandDefinition(
"SELECT 1 FROM dbo.City WHERE CityID = @CityId",
new { brewery.Location.CityId },
transaction
transaction,
cancellationToken: cancellationToken
)
)
is not null;
@@ -280,7 +298,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Description,
brewery.RowVersion,
},
transaction
transaction,
cancellationToken: cancellationToken
)
);
@@ -300,7 +319,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
transaction,
cancellationToken: cancellationToken
)
);
}
@@ -315,7 +335,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
WHERE BreweryPostID = @BreweryPostId
""",
new { brewery.BreweryPostId },
transaction
transaction,
cancellationToken: cancellationToken
)
)
is not null;
@@ -346,7 +367,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid,
},
transaction
transaction,
cancellationToken: cancellationToken
)
);
else
@@ -385,12 +407,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid,
},
transaction
transaction,
cancellationToken: cancellationToken
)
);
}
await transaction.CommitAsync();
await transaction.CommitAsync(cancellationToken);
}
catch
{
@@ -398,7 +421,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
throw;
}
return await GetByIdAsync(brewery.BreweryPostId)
return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
?? throw new InvalidOperationException(
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update."
);
@@ -542,25 +565,27 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a
/// single populated <see cref="BreweryPost" />. When the row has no location (a left-joined
/// brewery with no address on file), <paramref name="location" /> is a blank instance produced
/// from all-<c>NULL</c> columns; the schema guarantees that whenever a location row was joined
/// in, its City/StateProvince/Country chain was too.
/// brewery with no address on file), Dapper passes <see langword="null" /> for
/// <paramref name="location" /> rather than a blank instance, since its identity column
/// (<c>BreweryPostLocationID</c>) is <c>NULL</c>; the schema guarantees that whenever a location
/// row was joined in, its City/StateProvince/Country chain was too.
/// </summary>
private static BreweryPost MapBreweryRow(
BreweryPost post,
BreweryPostLocation location,
City city,
StateProvince stateProvince,
Country country
BreweryPostLocation? location,
City? city,
StateProvince? stateProvince,
Country? country
)
{
if (location.BreweryPostLocationId == Guid.Empty)
if (location is null)
return post;
stateProvince.CountryId = country.CountryId;
// The schema guarantees that whenever a location row was joined in, its City/StateProvince/
// Country chain was too, so these are never null alongside a non-null location.
city!.StateProvinceId = stateProvince!.StateProvinceId;
stateProvince.CountryId = country!.CountryId;
stateProvince.Country = country;
city.StateProvinceId = stateProvince.StateProvinceId;
city.StateProvince = stateProvince;
location.BreweryPostId = post.BreweryPostId;

View File

@@ -11,7 +11,13 @@ public interface IBreweryRepository
/// Retrieves a brewery post by ID, joined to its location. Returns <c>null</c> if no brewery post
/// exists with the given ID, or if it has no associated location.
/// </summary>
Task<BreweryPost?> GetByIdAsync(Guid id);
Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the <c>PostedById</c> of the brewery post with the given ID, without joining its
/// location. Returns <c>null</c> if no brewery post exists with the given ID.
/// </summary>
Task<Guid?> GetPostedByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts
@@ -50,7 +56,7 @@ public interface IBreweryRepository
/// Thrown when <paramref name="brewery" />'s <c>RowVersion</c> no longer matches the stored row (it was
/// modified by another request since it was last read).
/// </exception>
Task<BreweryPost> UpdateAsync(BreweryPost brewery);
Task<BreweryPost> UpdateAsync(BreweryPost brewery, CancellationToken cancellationToken = default);
/// <summary>Deletes a brewery post by ID. Its location and photos are removed via cascading foreign keys.</summary>
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no brewery exists with the given <paramref name="id" />.</exception>

View File

@@ -1,5 +1,3 @@
using System.IdentityModel.Tokens.Jwt;
using Domain.Exceptions;
using Features.Auth.Commands.Account.DeleteAccount;
using Features.Auth.Commands.Account.UpdateEmail;
using Features.Auth.Commands.Account.UpdatePassword;
@@ -31,18 +29,6 @@ namespace Features.Auth.Controllers;
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
/// </summary>
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
private Guid GetAuthenticatedUserId()
{
string? userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (!Guid.TryParse(userIdClaim, out Guid userId))
throw new UnauthorizedException("Access token is missing a valid user ID claim");
return userId;
}
/// <summary>Registers a new user account.</summary>
/// <remarks>
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
@@ -150,7 +136,7 @@ public class AuthController(IMediator mediator) : ControllerBase
)
{
UpdateUsernamePayload payload = await mediator.Send(
new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername)
new UpdateUsernameCommand(User.GetAuthenticatedUserId(), request.NewUsername)
);
return Ok(
new ResponseBody<UpdateUsernamePayload>
@@ -172,7 +158,7 @@ public class AuthController(IMediator mediator) : ControllerBase
)
{
UpdateEmailPayload payload = await mediator.Send(
new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail)
new UpdateEmailCommand(User.GetAuthenticatedUserId(), request.NewEmail)
);
return Ok(
new ResponseBody<UpdateEmailPayload>
@@ -195,7 +181,7 @@ public class AuthController(IMediator mediator) : ControllerBase
{
UpdatePasswordPayload payload = await mediator.Send(
new UpdatePasswordCommand(
GetAuthenticatedUserId(),
User.GetAuthenticatedUserId(),
request.CurrentPassword,
request.NewPassword
)
@@ -218,7 +204,7 @@ public class AuthController(IMediator mediator) : ControllerBase
{
UpdateProfilePayload payload = await mediator.Send(
new UpdateProfileCommand(
GetAuthenticatedUserId(),
User.GetAuthenticatedUserId(),
request.FirstName,
request.LastName,
request.DateOfBirth
@@ -241,7 +227,7 @@ public class AuthController(IMediator mediator) : ControllerBase
[HttpDelete("account")]
public async Task<ActionResult<ResponseBody>> DeleteAccount()
{
await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId()));
await mediator.Send(new DeleteAccountCommand(User.GetAuthenticatedUserId()));
return Ok(new ResponseBody { Message = "Account deleted successfully." });
}
}

View File

@@ -0,0 +1,21 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Exceptions;
namespace Shared.Contracts;
/// <summary>Extension methods for reading well-known claims off an authenticated <see cref="ClaimsPrincipal" />.</summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
/// </summary>
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
public static Guid GetAuthenticatedUserId(this ClaimsPrincipal user)
{
string? userIdClaim = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (!Guid.TryParse(userIdClaim, out Guid userId))
throw new UnauthorizedException("Access token is missing a valid user ID claim");
return userId;
}
}

View File

@@ -5,4 +5,12 @@
<Nullable>enable</Nullable>
<RootNamespace>Shared.Contracts</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
</ItemGroup>
</Project>

View File

@@ -119,6 +119,27 @@ services:
volumes:
- nuget-cache-dev:/root/.nuget/packages
frontend:
env_file: ".env.dev"
image: frontend
container_name: dev-env-frontend
depends_on:
api.core:
condition: service_started
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: "development"
PORT: "3000"
API_BASE_URL: "http://api.core:8080"
SESSION_SECRET: "${SESSION_SECRET}"
restart: unless-stopped
networks:
- devnet
seaweedfs:
env_file: ".env.dev"
image: chrislusf/seaweedfs

View File

@@ -59,9 +59,8 @@ services:
args:
BUILD_CONFIGURATION: Release
APP_UID: 1000
ports:
- "8080:8080"
- "8081:8081"
# No published ports: only the frontend service is reachable from outside
# the Docker network in production. api.core stays internal to prodnet.
environment:
ASPNETCORE_ENVIRONMENT: "Production"
ASPNETCORE_URLS: "http://0.0.0.0:8080"
@@ -80,6 +79,27 @@ services:
volumes:
- nuget-cache-prod:/root/.nuget/packages
frontend:
env_file: ".env.prod"
image: frontend
container_name: prod-env-frontend
depends_on:
api.core:
condition: service_started
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
NODE_ENV: "production"
PORT: "3000"
API_BASE_URL: "http://api.core:8080"
SESSION_SECRET: "${SESSION_SECRET}"
restart: unless-stopped
networks:
- prodnet
volumes:
sqlserverdata-prod:
driver: local

View File

@@ -139,6 +139,18 @@ services:
networks:
- testnet
frontend.tests:
image: frontend.tests
container_name: test-env-frontend-tests
build:
context: ./frontend
dockerfile: Dockerfile.tests
volumes:
- ./test-results:/app/test-results
restart: "no"
networks:
- testnet
volumes:
sqlserverdata-test:
driver: local

View File

@@ -0,0 +1,15 @@
**/.dockerignore
**/.env
**/.env.*
**/.git
**/.gitignore
**/.vscode
**/.idea
**/node_modules
**/build
**/.react-router
**/storybook-static
**/coverage
**/test-results
**/docker-compose*
**/Dockerfile*

29
web/frontend/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Stage 1: install every dependency (including dev deps needed to build)
FROM node:22-alpine AS deps
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: build the client and server bundles (react-router build)
FROM deps AS build
WORKDIR /src
COPY . .
RUN npm run build
# Stage 3: production-only dependencies for the runtime image
FROM node:22-alpine AS prod-deps
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 4: runtime
FROM node:22-alpine AS final
ENV NODE_ENV=production
WORKDIR /app
COPY package.json ./
COPY --from=prod-deps /src/node_modules ./node_modules
COPY --from=build /src/build ./build
COPY --from=build /src/server.js ./server.js
EXPOSE 3000
USER node
CMD ["node", "./server.js"]

View File

@@ -0,0 +1,10 @@
# Ships Playwright's own browsers preinstalled, matching the @playwright/test
# version pinned in package.json, so the Storybook Vitest and Playwright
# suites (both browser-driven) don't need a separate browser-install step.
FROM mcr.microsoft.com/playwright:v1.62.1-jammy AS test
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN mkdir -p /app/test-results/frontend
ENTRYPOINT ["sh", "-c", "set -e; npx vitest run --project storybook --reporter=junit --outputFile=/app/test-results/frontend/vitest-storybook.xml; PLAYWRIGHT_JUNIT_OUTPUT_NAME=/app/test-results/frontend/playwright-storybook.xml npx playwright test -c playwright.storybook.config.ts --reporter=junit"]

View File

@@ -13,6 +13,7 @@
"@react-router/dev": "^8.3.0",
"@react-router/express": "^8.3.0",
"@react-router/node": "^8.3.0",
"express": "^5.2.1",
"iconoir-react": "^7.11.1",
"isbot": "^5.2.1",
"leaflet": "^1.9.4",
@@ -3296,6 +3297,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -4618,7 +4685,6 @@
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
@@ -5004,7 +5070,6 @@
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
@@ -5029,7 +5094,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -5103,7 +5167,6 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -5326,7 +5389,6 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -5340,7 +5402,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -5356,7 +5417,6 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -5372,7 +5432,6 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.6.0"
}
@@ -5601,7 +5660,6 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -5664,8 +5722,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.412",
@@ -5688,7 +5745,6 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -5963,8 +6019,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
@@ -6314,7 +6369,6 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -6346,7 +6400,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -6447,7 +6500,6 @@
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
@@ -6523,7 +6575,6 @@
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -6547,7 +6598,6 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -6932,7 +6982,6 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
@@ -6966,7 +7015,6 @@
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -7029,8 +7077,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
@@ -7052,7 +7099,6 @@
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.10"
}
@@ -7349,8 +7395,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
@@ -8106,7 +8151,6 @@
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
},
@@ -8120,7 +8164,6 @@
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -8133,7 +8176,6 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@@ -8143,7 +8185,6 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
@@ -8244,7 +8285,6 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
"integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"content-type": "^2.1.0"
},
@@ -8261,7 +8301,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -8434,7 +8473,6 @@
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"peer": true,
"dependencies": {
"ee-first": "1.1.1"
},
@@ -8447,7 +8485,6 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"peer": true,
"dependencies": {
"wrappy": "1"
}
@@ -8639,7 +8676,6 @@
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -8703,7 +8739,6 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@@ -8918,7 +8953,6 @@
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -8942,7 +8976,6 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
@@ -8959,7 +8992,6 @@
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
},
@@ -8973,7 +9005,6 @@
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
@@ -9342,7 +9373,6 @@
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
@@ -9426,8 +9456,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
@@ -9452,7 +9481,6 @@
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
@@ -9479,7 +9507,6 @@
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
@@ -9547,8 +9574,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
@@ -9698,7 +9724,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -10068,7 +10093,6 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.6"
}
@@ -10145,7 +10169,6 @@
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"peer": true,
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
@@ -10164,7 +10187,6 @@
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -10330,7 +10352,6 @@
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -10419,7 +10440,6 @@
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -10773,8 +10793,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",

View File

@@ -5,7 +5,7 @@
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
"start": "NODE_ENV=production node ./build/server/index.js",
"start": "NODE_ENV=production node ./server.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
@@ -23,6 +23,7 @@
"@react-router/dev": "^8.3.0",
"@react-router/express": "^8.3.0",
"@react-router/node": "^8.3.0",
"express": "^5.2.1",
"iconoir-react": "^7.11.1",
"isbot": "^5.2.1",
"leaflet": "^1.9.4",

18
web/frontend/server.js Normal file
View File

@@ -0,0 +1,18 @@
import { createRequestHandler } from '@react-router/express';
import express from 'express';
const app = express();
app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' }));
app.use(express.static('build/client', { maxAge: '1h' }));
const requestHandler = createRequestHandler({
build: () => import('./build/server/index.js'),
});
app.all('/', requestHandler);
app.all('/*splat', requestHandler);
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});

View File

@@ -61,6 +61,7 @@ DB_CONNECTION_STRING_VAL="Server=${DB_SERVER_VAL};Database=${DB_NAME_VAL};User I
ACCESS_TOKEN_SECRET_VAL=$(gen_jwt_secret)
REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret)
CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret)
SESSION_SECRET_VAL=$(gen_jwt_secret)
SMTP_USERNAME_VAL="smtp_$(gen_username)"
SMTP_PASSWORD_VAL=$(gen_password)
SEAWEEDFS_ACCESS_KEY_ID_VAL=$(gen_username)
@@ -75,6 +76,7 @@ sed \
-e "s|^ACCESS_TOKEN_SECRET=.*|ACCESS_TOKEN_SECRET=${ACCESS_TOKEN_SECRET_VAL}|" \
-e "s|^REFRESH_TOKEN_SECRET=.*|REFRESH_TOKEN_SECRET=${REFRESH_TOKEN_SECRET_VAL}|" \
-e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \
-e "s|^SESSION_SECRET=.*|SESSION_SECRET=${SESSION_SECRET_VAL}|" \
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \
-e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \
-e "s|^SEAWEEDFS_ACCESS_KEY_ID=.*|SEAWEEDFS_ACCESS_KEY_ID=${SEAWEEDFS_ACCESS_KEY_ID_VAL}|" \