Enforce brewery ownership on update and delete (#267)

This commit is contained in:
Aaron Po
2026-08-31 13:37:17 -04:00
committed by GitHub
parent 49d7f860de
commit 8b429be857
31 changed files with 978 additions and 287 deletions

View File

@@ -41,5 +41,7 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <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> </ItemGroup>
</Project> </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; namespace API.Specs.Steps;
[Binding] [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 TestUserKey = "testUser";
private const string RegisteredUserIdKey = "registeredUserId"; private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername"; private const string RegisteredUsernameKey = "registeredUsername";
private const string PreviousAccessTokenKey = "previousAccessToken"; private const string PreviousAccessTokenKey = "previousAccessToken";
private const string PreviousRefreshTokenKey = "previousRefreshToken"; 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> /// <summary>
/// Reads a required configuration value from the running <see cref="TestApiFactory" />'s own /// 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 /// <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")] [Given("I have an existing account")]
public async Task GivenIHaveAnExistingAccount() public async Task GivenIHaveAnExistingAccount()
{ {
scenario[TestUserKey] = (FixtureUsername, FixturePassword); Scenario[TestUserKey] = (FixtureUsername, FixturePassword);
HttpClient client = GetClient(); HttpClient client = GetClient();
var registrationData = new var registrationData = new
@@ -153,14 +127,14 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I do not have an existing account")] [Given("I do not have an existing account")]
public void GivenIDoNotHaveAnExistingAccount() public void GivenIDoNotHaveAnExistingAccount()
{ {
scenario[TestUserKey] = ("Failing", "User"); Scenario[TestUserKey] = ("Failing", "User");
} }
[When("I submit a login request with a username and password")] [When("I submit a login request with a username and password")]
public async Task WhenISubmitALoginRequestWithAUsernameAndPassword() public async Task WhenISubmitALoginRequestWithAUsernameAndPassword()
{ {
HttpClient client = GetClient(); 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 ? user
: ("test.user", "password"); : ("test.user", "password");
@@ -174,8 +148,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a login request with a missing username")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a login request with a missing password")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a login request with both username and password missing")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[Then("the response JSON should have an access token")] [Then("the response JSON should have an access token")]
public void ThenTheResponseJsonShouldHaveAnAccessToken() public void ThenTheResponseJsonShouldHaveAnAccessToken()
{ {
scenario Scenario
.TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response) .TryGetValue<HttpResponseMessage>(ResponseKey, out HttpResponseMessage? response)
.Should() .Should()
.BeTrue(); .BeTrue();
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue(); Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
JsonDocument doc = JsonDocument.Parse(responseBody!); JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement; JsonElement root = doc.RootElement;
@@ -270,8 +244,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a registration request with values:")] [When("I submit a registration request with values:")]
@@ -321,8 +295,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a registration request using a GET request")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[Given("I have registered a new account")] [Given("I have registered a new account")]
@@ -364,13 +338,13 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
using JsonDocument doc = JsonDocument.Parse(responseBody); using JsonDocument doc = JsonDocument.Parse(responseBody);
JsonElement root = doc.RootElement; JsonElement root = doc.RootElement;
scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root); Scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root); Scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
} }
[Given("I am logged in")] [Given("I am logged in")]
@@ -396,12 +370,12 @@ public class AuthSteps(ScenarioContext scenario)
payloadElem.TryGetProperty("accessToken", out JsonElement tokenElem) payloadElem.TryGetProperty("accessToken", out JsonElement tokenElem)
|| payloadElem.TryGetProperty("AccessToken", out tokenElem) || payloadElem.TryGetProperty("AccessToken", out tokenElem)
) )
scenario["accessToken"] = tokenElem.GetString(); Scenario["accessToken"] = tokenElem.GetString();
if ( if (
payloadElem.TryGetProperty("refreshToken", out JsonElement refreshElem) payloadElem.TryGetProperty("refreshToken", out JsonElement refreshElem)
|| payloadElem.TryGetProperty("RefreshToken", out 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")] [Given("I have a valid access token for my account")]
public void GivenIHaveAValidAccessTokenForMyAccount() public void GivenIHaveAValidAccessTokenForMyAccount()
{ {
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException("registered user ID not found in scenario"); : 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 ? user
: throw new InvalidOperationException("registered username not found in scenario"); : throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.AccessTokenSecret); string secret = GetRequiredConfigValue(ConfigurationKeys.AccessTokenSecret);
scenario["accessToken"] = GenerateJwtToken( Scenario["accessToken"] = GenerateJwtToken(
userId, userId,
username, username,
secret, secret,
@@ -440,15 +414,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a valid confirmation token for my account")] [Given("I have a valid confirmation token for my account")]
public void GivenIHaveAValidConfirmationTokenForMyAccount() public void GivenIHaveAValidConfirmationTokenForMyAccount()
{ {
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException("registered user ID not found in scenario"); : 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 ? user
: throw new InvalidOperationException("registered username not found in scenario"); : throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret); string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
scenario["confirmationToken"] = GenerateJwtToken( Scenario["confirmationToken"] = GenerateJwtToken(
userId, userId,
username, username,
secret, secret,
@@ -459,15 +433,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have an expired confirmation token for my account")] [Given("I have an expired confirmation token for my account")]
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount() public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
{ {
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException("registered user ID not found in scenario"); : 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 ? user
: throw new InvalidOperationException("registered username not found in scenario"); : throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret); string secret = GetRequiredConfigValue(ConfigurationKeys.ConfirmationTokenSecret);
scenario["confirmationToken"] = GenerateJwtToken( Scenario["confirmationToken"] = GenerateJwtToken(
userId, userId,
username, username,
secret, secret,
@@ -478,15 +452,15 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have a confirmation token signed with the wrong secret")] [Given("I have a confirmation token signed with the wrong secret")]
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret() public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
{ {
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException("registered user ID not found in scenario"); : 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 ? user
: throw new InvalidOperationException("registered username not found in scenario"); : throw new InvalidOperationException("registered username not found in scenario");
const string wrongSecret = "wrong-confirmation-secret-that-is-very-long-1234567890"; const string wrongSecret = "wrong-confirmation-secret-that-is-very-long-1234567890";
scenario["confirmationToken"] = GenerateJwtToken( Scenario["confirmationToken"] = GenerateJwtToken(
userId, userId,
username, username,
wrongSecret, wrongSecret,
@@ -498,7 +472,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken() public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("accessToken", out string? t) string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
? t ? t
: "invalid-token"; : "invalid-token";
@@ -509,8 +483,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a request to a protected endpoint with an invalid access token")] [When("I submit a request to a protected endpoint with an invalid access token")]
@@ -524,18 +498,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request with the valid token")] [When("I submit a confirmation request with the valid token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidToken() public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: "valid-token"; : "valid-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at) string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at ? at
: string.Empty; : string.Empty;
@@ -548,18 +522,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit the same confirmation request again")] [When("I submit the same confirmation request again")]
public async Task WhenISubmitTheSameConfirmationRequestAgain() public async Task WhenISubmitTheSameConfirmationRequestAgain()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: "valid-token"; : "valid-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at) string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at ? at
: string.Empty; : string.Empty;
@@ -572,8 +546,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request with a malformed token")] [When("I submit a confirmation request with a malformed token")]
@@ -581,7 +555,7 @@ public class AuthSteps(ScenarioContext scenario)
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
const string token = "malformed-token-not-jwt"; 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 ? at
: string.Empty; : string.Empty;
@@ -594,20 +568,20 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a refresh token request with a valid refresh token")] [When("I submit a refresh token request with a valid refresh token")]
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken() public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
if (scenario.TryGetValue<string>("accessToken", out string? oldAccessToken)) if (Scenario.TryGetValue<string>("accessToken", out string? oldAccessToken))
scenario[PreviousAccessTokenKey] = oldAccessToken; Scenario[PreviousAccessTokenKey] = oldAccessToken;
if (scenario.TryGetValue<string>("refreshToken", out string? oldRefreshToken)) if (Scenario.TryGetValue<string>("refreshToken", out string? oldRefreshToken))
scenario[PreviousRefreshTokenKey] = oldRefreshToken; Scenario[PreviousRefreshTokenKey] = oldRefreshToken;
string? token = scenario.TryGetValue<string>("refreshToken", out string? t) string? token = Scenario.TryGetValue<string>("refreshToken", out string? t)
? t ? t
: "valid-refresh-token"; : "valid-refresh-token";
@@ -616,8 +590,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a refresh token request with an invalid refresh token")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a refresh token request with the expired refresh token")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a refresh token request with a missing refresh token")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a refresh token request using a GET request")] [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); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
// Protected Endpoint Steps // Protected Endpoint Steps
@@ -685,22 +659,22 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[Given("I am logged in with an immediately-expiring access token")] [Given("I am logged in with an immediately-expiring access token")]
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken() public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
{ {
// Simulate an expired access token for auth rejection behavior. // Simulate an expired access token for auth rejection behavior.
scenario["accessToken"] = "expired-access-token"; Scenario["accessToken"] = "expired-access-token";
return Task.CompletedTask; return Task.CompletedTask;
} }
[Given("I have an access token signed with the wrong secret")] [Given("I have an access token signed with the wrong secret")]
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret() public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
{ {
scenario["accessToken"] = Scenario["accessToken"] =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
} }
@@ -708,7 +682,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken() public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("accessToken", out string? t) string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
? t ? t
: "expired-token"; : "expired-token";
@@ -719,15 +693,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a request to a protected endpoint with the tampered token")] [When("I submit a request to a protected endpoint with the tampered token")]
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken() public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("accessToken", out string? t) string? token = Scenario.TryGetValue<string>("accessToken", out string? t)
? t ? t
: "tampered-token"; : "tampered-token";
@@ -738,8 +712,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When( [When(
@@ -748,7 +722,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken() public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("refreshToken", out string? t) string? token = Scenario.TryGetValue<string>("refreshToken", out string? t)
? t ? t
: "refresh-token"; : "refresh-token";
@@ -759,24 +733,24 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[Given("I have a valid confirmation token")] [Given("I have a valid confirmation token")]
public void GivenIHaveAValidConfirmationToken() public void GivenIHaveAValidConfirmationToken()
{ {
scenario["confirmationToken"] = "valid-confirmation-token"; Scenario["confirmationToken"] = "valid-confirmation-token";
} }
[When("I submit a confirmation request with the expired token")] [When("I submit a confirmation request with the expired token")]
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken() public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: "expired-confirmation-token"; : "expired-confirmation-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at) string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at ? at
: string.Empty; : string.Empty;
@@ -789,18 +763,18 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request with the tampered token")] [When("I submit a confirmation request with the tampered token")]
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken() public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: "tampered-confirmation-token"; : "tampered-confirmation-token";
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at) string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at ? at
: string.Empty; : string.Empty;
@@ -813,15 +787,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request with a missing token")] [When("I submit a confirmation request with a missing token")]
public async Task WhenISubmitAConfirmationRequestWithAMissingToken() public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at) string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at ? at
: string.Empty; : string.Empty;
@@ -831,15 +805,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request using an invalid HTTP method")] [When("I submit a confirmation request using an invalid HTTP method")]
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod() public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: "valid-confirmation-token"; : "valid-confirmation-token";
@@ -850,8 +824,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When( [When(
@@ -860,7 +834,7 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken() public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: "confirmation-token"; : "confirmation-token";
@@ -871,8 +845,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request with an invalid token")] [When("I submit a confirmation request with an invalid token")]
@@ -880,7 +854,7 @@ public class AuthSteps(ScenarioContext scenario)
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
const string token = "invalid-confirmation-token"; 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 ? at
: string.Empty; : string.Empty;
@@ -893,15 +867,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a confirmation request with the valid token without an access token")] [When("I submit a confirmation request with the valid token without an access token")]
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken() public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: "valid-token"; : "valid-token";
@@ -912,14 +886,14 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[Then("the response JSON should have a new access token")] [Then("the response JSON should have a new access token")]
public void ThenTheResponseJsonShouldHaveANewAccessToken() 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!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload"); JsonElement payload = doc.RootElement.GetProperty("payload");
@@ -927,14 +901,14 @@ public class AuthSteps(ScenarioContext scenario)
accessToken.Should().NotBeNullOrWhiteSpace(); accessToken.Should().NotBeNullOrWhiteSpace();
if (scenario.TryGetValue<string>(PreviousAccessTokenKey, out string? previousAccessToken)) if (Scenario.TryGetValue<string>(PreviousAccessTokenKey, out string? previousAccessToken))
accessToken.Should().NotBe(previousAccessToken); accessToken.Should().NotBe(previousAccessToken);
} }
[Then("the response JSON should have a new refresh token")] [Then("the response JSON should have a new refresh token")]
public void ThenTheResponseJsonShouldHaveANewRefreshToken() 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!); using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement payload = doc.RootElement.GetProperty("payload"); JsonElement payload = doc.RootElement.GetProperty("payload");
@@ -942,7 +916,7 @@ public class AuthSteps(ScenarioContext scenario)
refreshToken.Should().NotBeNullOrWhiteSpace(); refreshToken.Should().NotBeNullOrWhiteSpace();
if (scenario.TryGetValue<string>(PreviousRefreshTokenKey, out string? previousRefreshToken)) if (Scenario.TryGetValue<string>(PreviousRefreshTokenKey, out string? previousRefreshToken))
refreshToken.Should().NotBe(previousRefreshToken); refreshToken.Should().NotBe(previousRefreshToken);
} }
@@ -950,10 +924,10 @@ public class AuthSteps(ScenarioContext scenario)
public async Task GivenIHaveConfirmedMyAccount() public async Task GivenIHaveConfirmedMyAccount()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
string? token = scenario.TryGetValue<string>("confirmationToken", out string? t) string? token = Scenario.TryGetValue<string>("confirmationToken", out string? t)
? t ? t
: throw new InvalidOperationException("confirmation token not found"); : 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 ? at
: string.Empty; : string.Empty;
@@ -972,10 +946,10 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitAResendConfirmationRequestForMyAccount() public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
Guid userId = scenario.TryGetValue(RegisteredUserIdKey, out Guid id) Guid userId = Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id ? id
: throw new InvalidOperationException("registered user ID not found"); : 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 ? at
: string.Empty; : string.Empty;
@@ -988,8 +962,8 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a resend confirmation request for a non-existent user")] [When("I submit a resend confirmation request for a non-existent user")]
@@ -997,7 +971,7 @@ public class AuthSteps(ScenarioContext scenario)
{ {
HttpClient client = GetClient(); HttpClient client = GetClient();
Guid fakeUserId = Guid.NewGuid(); Guid fakeUserId = Guid.NewGuid();
string? accessToken = scenario.TryGetValue<string>("accessToken", out string? at) string? accessToken = Scenario.TryGetValue<string>("accessToken", out string? at)
? at ? at
: string.Empty; : string.Empty;
@@ -1010,15 +984,15 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; Scenario[ResponseBodyKey] = responseBody;
} }
[When("I submit a resend confirmation request without an access token")] [When("I submit a resend confirmation request without an access token")]
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken() public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
{ {
HttpClient client = GetClient(); 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( HttpRequestMessage requestMessage = new(
HttpMethod.Post, HttpMethod.Post,
@@ -1027,7 +1001,7 @@ public class AuthSteps(ScenarioContext scenario)
HttpResponseMessage response = await client.SendAsync(requestMessage); HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync(); string responseBody = await response.Content.ReadAsStringAsync();
scenario[ResponseKey] = response; Scenario[ResponseKey] = response;
scenario[ResponseBodyKey] = responseBody; 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; } public Guid BreweryPostId { get; set; }
// FK References // FK References
// Note: An empty GUID signifies that this object is incomplete
public Guid PostedById { get; set; } public Guid PostedById { get; set; }
public string PostedBy { get; set; } = string.Empty;
// Attributes // Attributes
public string BreweryName { get; set; } = string.Empty; public string BreweryName { get; set; } = string.Empty;
@@ -21,6 +21,7 @@ public sealed class BreweryPost
// Navigation Properties // Navigation Properties
public BreweryPostLocation? Location { get; set; } public BreweryPostLocation? Location { get; set; }
public List<BeerPost> BeerPosts { get; set; } = []; public List<BeerPost> BeerPosts { get; set; } = [];
public string PostedBy { get; set; } = string.Empty;
// Query-computed // Query-computed
public DistanceInformation? Distance { get; set; } public DistanceInformation? Distance { get; set; }

View File

@@ -32,6 +32,9 @@ public class CreateBreweryHandlerTests
.Setup(r => r.CreateAsync(It.IsAny<BreweryPost>())) .Setup(r => r.CreateAsync(It.IsAny<BreweryPost>()))
.Callback<BreweryPost>(b => persisted = b) .Callback<BreweryPost>(b => persisted = b)
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
_repoMock
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() => persisted);
DateTime before = DateTime.UtcNow; DateTime before = DateTime.UtcNow;
BreweryDto result = await _handler.Handle(command, CancellationToken.None); BreweryDto result = await _handler.Handle(command, CancellationToken.None);
@@ -53,8 +56,27 @@ public class CreateBreweryHandlerTests
persisted.Location.PostalCode.Should().Be(command.Location.PostalCode); persisted.Location.PostalCode.Should().Be(command.Location.PostalCode);
_repoMock.Verify(r => r.CreateAsync(It.IsAny<BreweryPost>()), Times.Once); _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.BreweryName.Should().Be("MyBrew");
result.Location.Should().NotBeNull(); 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.Commands.DeleteBrewery;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions;
using Moq; using Moq;
namespace Features.Breweries.Tests.Commands; namespace Features.Breweries.Tests.Commands;
public class DeleteBreweryHandlerTests public class DeleteBreweryHandlerTests
{ {
[Fact] private readonly DeleteBreweryHandler _handler;
public async Task Handle_DelegatesToRepository() private readonly Mock<IBreweryRepository> _repoMock = new();
public DeleteBreweryHandlerTests()
{ {
Mock<IBreweryRepository> repoMock = new(); _handler = new DeleteBreweryHandler(_repoMock.Object);
DeleteBreweryHandler handler = new(repoMock.Object); }
Guid id = Guid.NewGuid();
repoMock.Setup(r => r.DeleteAsync(id)).Returns(Task.CompletedTask);
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.Entities;
using Domain.Exceptions;
using Features.Breweries.Commands.UpdateBrewery; using Features.Breweries.Commands.UpdateBrewery;
using Features.Breweries.Repository; using Features.Breweries.Repository;
using FluentAssertions; using FluentAssertions;
@@ -16,13 +17,24 @@ public class UpdateBreweryHandlerTests
_handler = new UpdateBreweryHandler(_repoMock.Object); _handler = new UpdateBreweryHandler(_repoMock.Object);
} }
private void SetUpExistingBrewery(Guid breweryPostId, Guid postedById)
{
_repoMock
.Setup(r => r.GetPostedByIdAsync(breweryPostId, It.IsAny<CancellationToken>()))
.ReturnsAsync(postedById);
}
[Fact] [Fact]
public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt() public async Task Handle_UpdatesNameDescription_AndSetsUpdatedAt()
{ {
Guid breweryPostId = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, requestingUserId);
UpdateBreweryCommand command = new( UpdateBreweryCommand command = new(
Guid.NewGuid(), breweryPostId,
requestingUserId,
[0x01, 0x02], [0x01, 0x02],
Guid.NewGuid(),
"Renamed", "Renamed",
"New description", "New description",
null null
@@ -30,8 +42,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null; BreweryPost? persisted = null;
_repoMock _repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>())) .Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
.Callback<BreweryPost>(b => persisted = b) .Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
.ReturnsAsync(() => persisted!); .ReturnsAsync(() => persisted!);
DateTime before = DateTime.UtcNow; DateTime before = DateTime.UtcNow;
@@ -40,7 +52,6 @@ public class UpdateBreweryHandlerTests
persisted.Should().NotBeNull(); persisted.Should().NotBeNull();
persisted!.BreweryPostId.Should().Be(command.BreweryPostId); persisted!.BreweryPostId.Should().Be(command.BreweryPostId);
persisted.PostedById.Should().Be(command.PostedById);
persisted.BreweryName.Should().Be("Renamed"); persisted.BreweryName.Should().Be("Renamed");
persisted.Description.Should().Be("New description"); persisted.Description.Should().Be("New description");
persisted.RowVersion.Should().Equal(command.RowVersion); persisted.RowVersion.Should().Equal(command.RowVersion);
@@ -51,10 +62,14 @@ public class UpdateBreweryHandlerTests
[Fact] [Fact]
public async Task Handle_ClearsLocation_WhenCommandLocationIsNull() public async Task Handle_ClearsLocation_WhenCommandLocationIsNull()
{ {
Guid breweryPostId = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, requestingUserId);
UpdateBreweryCommand command = new( UpdateBreweryCommand command = new(
Guid.NewGuid(), breweryPostId,
requestingUserId,
[0x01, 0x02], [0x01, 0x02],
Guid.NewGuid(),
"Name", "Name",
"Description", "Description",
null null
@@ -62,8 +77,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null; BreweryPost? persisted = null;
_repoMock _repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>())) .Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
.Callback<BreweryPost>(b => persisted = b) .Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
.ReturnsAsync(() => persisted!); .ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
@@ -74,6 +89,10 @@ public class UpdateBreweryHandlerTests
[Fact] [Fact]
public async Task Handle_SetsLocation_WhenCommandLocationProvided() public async Task Handle_SetsLocation_WhenCommandLocationProvided()
{ {
Guid breweryPostId = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
SetUpExistingBrewery(breweryPostId, requestingUserId);
UpdateBreweryLocation locationCommand = new( UpdateBreweryLocation locationCommand = new(
Guid.NewGuid(), Guid.NewGuid(),
Guid.NewGuid(), Guid.NewGuid(),
@@ -83,9 +102,9 @@ public class UpdateBreweryHandlerTests
null null
); );
UpdateBreweryCommand command = new( UpdateBreweryCommand command = new(
Guid.NewGuid(), breweryPostId,
requestingUserId,
[0x01, 0x02], [0x01, 0x02],
Guid.NewGuid(),
"Name", "Name",
"Description", "Description",
locationCommand locationCommand
@@ -93,8 +112,8 @@ public class UpdateBreweryHandlerTests
BreweryPost? persisted = null; BreweryPost? persisted = null;
_repoMock _repoMock
.Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>())) .Setup(r => r.UpdateAsync(It.IsAny<BreweryPost>(), It.IsAny<CancellationToken>()))
.Callback<BreweryPost>(b => persisted = b) .Callback<BreweryPost, CancellationToken>((b, _) => persisted = b)
.ReturnsAsync(() => persisted!); .ReturnsAsync(() => persisted!);
await _handler.Handle(command, CancellationToken.None); await _handler.Handle(command, CancellationToken.None);
@@ -109,4 +128,41 @@ public class UpdateBreweryHandlerTests
persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2); persisted.Location.AddressLine2.Should().Be(locationCommand.AddressLine2);
persisted.Location.PostalCode.Should().Be(locationCommand.PostalCode); 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>()); _repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty<BreweryPost>());
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle( IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
new GetAllBreweryLocationsQuery(), new GetAllBreweryLocationsQuery(),
CancellationToken.None CancellationToken.None
); );
@@ -41,7 +41,7 @@ public class GetAllBreweryLocationsHandlerTests
]; ];
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries); _repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries);
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle( IEnumerable<BreweryWithLocationDto> result = await _handler.Handle(
new GetAllBreweryLocationsQuery(), new GetAllBreweryLocationsQuery(),
CancellationToken.None CancellationToken.None
); );

View File

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

View File

@@ -23,3 +23,10 @@ public record CreateBreweryCommand(
string Description, string Description,
CreateBreweryLocation Location CreateBreweryLocation Location
) : IRequest<BreweryDto>; ) : 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); 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; namespace Features.Breweries.Commands.DeleteBrewery;
/// <summary>Deletes a brewery post. Bound directly from the route of <c>DELETE /api/brewery/{id}</c>.</summary> /// <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) : IRequest; public record DeleteBreweryCommand(Guid BreweryPostId, Guid RequestingUserId) : IRequest;

View File

@@ -10,8 +10,17 @@ public class DeleteBreweryHandler(IBreweryRepository repository)
/// <exception cref="Domain.Exceptions.NotFoundException"> /// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>. /// Thrown when no brewery exists with <paramref name="request" />'s <c>BreweryPostId</c>.
/// </exception> /// </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> /// <summary>
/// Updates an existing brewery post. Bound directly from the request body of <c>PUT /api/brewery/{id}</c>. /// Updates an existing brewery post. A <c>null</c> <see cref="Location" /> clears the brewery's location.
/// A <c>null</c> <see cref="Location" /> clears the brewery's location. <see cref="RowVersion" /> must be the /// <see cref="RowVersion" /> must be the row-version last read for this brewery (e.g. from a prior
/// row-version last read for this brewery (e.g. from a prior <c>GET</c>); the update is rejected with a /// <c>GET</c>); the update is rejected with a <c>409 Conflict</c> if the brewery was modified since then.
/// <c>409 Conflict</c> if the brewery was modified since then.
/// </summary> /// </summary>
public record UpdateBreweryCommand( public record UpdateBreweryCommand(
Guid BreweryPostId, Guid BreweryPostId,
Guid RequestingUserId,
byte[] RowVersion, byte[] RowVersion,
Guid PostedById,
string BreweryName, string BreweryName,
string Description, string Description,
UpdateBreweryLocation? Location UpdateBreweryLocation? Location
) : IRequest<BreweryDto>; ) : 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 CancellationToken cancellationToken
) )
{ {
await repository.EnsureCallerOwnsBreweryAsync(
request.BreweryPostId,
request.RequestingUserId,
cancellationToken
);
BreweryPost entity = new() BreweryPost entity = new()
{ {
BreweryPostId = request.BreweryPostId, BreweryPostId = request.BreweryPostId,
PostedById = request.PostedById, RowVersion = request.RowVersion,
BreweryName = request.BreweryName, BreweryName = request.BreweryName,
Description = request.Description, Description = request.Description,
RowVersion = request.RowVersion,
UpdatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
Location = request.Location is null Location = request.Location is null
? 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(); return updated.ToDto();
} }
} }

View File

@@ -70,13 +70,13 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("locations")] [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() new GetAllBreweryLocationsQuery()
); );
return Ok( return Ok(
new ResponseBody<IEnumerable<SimplifiedBreweryDto>> new ResponseBody<IEnumerable<BreweryWithLocationDto>>
{ {
Message = "Brewery locations retrieved successfully.", Message = "Brewery locations retrieved successfully.",
Payload = breweries, Payload = breweries,
@@ -91,17 +91,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <remarks>Allows anonymous access.</remarks> /// <remarks>Allows anonymous access.</remarks>
[AllowAnonymous] [AllowAnonymous]
[HttpGet("locations/nearby")] [HttpGet("locations/nearby")]
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetLocationsWithinRange( public async Task<ActionResult<ResponseBody<IEnumerable<BreweryWithLocationDto>>>> GetLocationsWithinRange(
[FromQuery] double latitude, [FromQuery] double latitude,
[FromQuery] double longitude, [FromQuery] double longitude,
[FromQuery] double rangeInMetres [FromQuery] double rangeInMetres
) )
{ {
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send( IEnumerable<BreweryWithLocationDto> breweries = await mediator.Send(
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres) new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
); );
return Ok( return Ok(
new ResponseBody<IEnumerable<SimplifiedBreweryDto>> new ResponseBody<IEnumerable<BreweryWithLocationDto>>
{ {
Message = "Brewery locations retrieved successfully.", Message = "Brewery locations retrieved successfully.",
Payload = breweries, Payload = breweries,
@@ -113,10 +113,17 @@ public class BreweryController(IMediator mediator) : ControllerBase
/// <returns><c>201 Created</c> with the newly created brewery.</returns> /// <returns><c>201 Created</c> with the newly created brewery.</returns>
[HttpPost] [HttpPost]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Create( 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( return Created(
$"/api/brewery/{brewery.BreweryPostId}", $"/api/brewery/{brewery.BreweryPostId}",
new ResponseBody<BreweryDto> new ResponseBody<BreweryDto>
@@ -128,27 +135,37 @@ public class BreweryController(IMediator mediator) : ControllerBase
} }
/// <summary>Updates an existing brewery post.</summary> /// <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> /// <returns>
/// <c>200 OK</c> with the updated brewery; <c>400 Bad Request</c> if the route ID does not match the /// <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 /// 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> /// </returns>
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
public async Task<ActionResult<ResponseBody<BreweryDto>>> Update( public async Task<ActionResult<ResponseBody<BreweryDto>>> Update(
Guid id, 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." }); 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( return Ok(
new ResponseBody<BreweryDto> new ResponseBody<BreweryDto>
{ {
Message = "Brewery updated successfully.", Message = "Brewery updated successfully.",
Payload = brewery, Payload = breweryUpdated,
} }
); );
} }
@@ -158,7 +175,7 @@ public class BreweryController(IMediator mediator) : ControllerBase
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<ActionResult<ResponseBody>> Delete(Guid id) 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." }); 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 /// 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>). /// origin point (e.g. <c>GET /api/brewery/locations</c>).
/// </param> /// </param>
public record SimplifiedBreweryDto( public record BreweryWithLocationDto(
Guid BreweryPostId, Guid BreweryPostId,
string BreweryName, string BreweryName,
BreweryLocationDto? Location, BreweryLocationDto? Location,

View File

@@ -45,9 +45,9 @@ public static class BreweryDtoMapper
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for /// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for
/// location-based queries that only need identifying and location details. /// location-based queries that only need identifying and location details.
/// </summary> /// </summary>
public static SimplifiedBreweryDto ToSimplifiedDto(this BreweryPost brewery) public static BreweryWithLocationDto ToSimplifiedDto(this BreweryPost brewery)
{ {
return new SimplifiedBreweryDto( return new BreweryWithLocationDto(
BreweryPostId: brewery.BreweryPostId, BreweryPostId: brewery.BreweryPostId,
BreweryName: brewery.BreweryName, BreweryName: brewery.BreweryName,
Location: brewery.Location is null 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> /// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary>
public class GetAllBreweryLocationsHandler(IBreweryRepository repository) public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<SimplifiedBreweryDto>> : IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<BreweryWithLocationDto>>
{ {
/// <summary>Retrieves every brewery post that has a set location.</summary> /// <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, GetAllBreweryLocationsQuery request,
CancellationToken cancellationToken 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 /// Retrieves all brewery posts that have a set location. Bound directly from
/// <c>GET /api/brewery/locations</c>. /// <c>GET /api/brewery/locations</c>.
/// </summary> /// </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 CancellationToken cancellationToken
) )
{ {
BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId); BreweryPost? brewery = await repository.GetByIdAsync(request.BreweryPostId, cancellationToken);
return brewery?.ToDto(); return brewery?.ToDto();
} }
} }

View File

@@ -9,10 +9,10 @@ namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts. /// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
/// </summary> /// </summary>
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository) 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> /// <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, GetBreweryLocationsWithinRangeQuery request,
CancellationToken cancellationToken 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="Longitude">The origin longitude, in decimal degrees.</param>
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param> /// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres) 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/> /// <inheritdoc/>
public async Task<BreweryPost?> GetByIdAsync(Guid id) public async Task<BreweryPost?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
IEnumerable<BreweryPost> results = await connection.QueryAsync< IEnumerable<BreweryPost> results = await connection.QueryAsync<
@@ -39,6 +39,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Country, Country,
BreweryPost BreweryPost
>( >(
new CommandDefinition(
""" """
SELECT SELECT
bp.BreweryPostID, bp.BreweryPostID,
@@ -68,14 +69,29 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
WHERE bp.BreweryPostID = @BreweryPostId WHERE bp.BreweryPostID = @BreweryPostId
""", """,
MapBreweryRow,
new { BreweryPostId = id }, new { BreweryPostId = id },
cancellationToken: cancellationToken
),
MapBreweryRow,
splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID"
); );
return results.SingleOrDefault(); 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/> /// <inheritdoc/>
public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) public async Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset)
{ {
@@ -221,10 +237,10 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
} }
/// <inheritdoc/> /// <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 DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(); await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
try try
{ {
@@ -238,7 +254,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
BreweryPostID = @BreweryPostId BreweryPostID = @BreweryPostId
""", """,
new { brewery.BreweryPostId }, new { brewery.BreweryPostId },
transaction transaction,
cancellationToken: cancellationToken
) )
) )
is not null; is not null;
@@ -253,7 +270,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
new CommandDefinition( new CommandDefinition(
"SELECT 1 FROM dbo.City WHERE CityID = @CityId", "SELECT 1 FROM dbo.City WHERE CityID = @CityId",
new { brewery.Location.CityId }, new { brewery.Location.CityId },
transaction transaction,
cancellationToken: cancellationToken
) )
) )
is not null; is not null;
@@ -280,7 +298,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
brewery.Description, brewery.Description,
brewery.RowVersion, brewery.RowVersion,
}, },
transaction transaction,
cancellationToken: cancellationToken
) )
); );
@@ -300,7 +319,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
WHERE BreweryPostID = @BreweryPostId WHERE BreweryPostID = @BreweryPostId
""", """,
new { brewery.BreweryPostId }, new { brewery.BreweryPostId },
transaction transaction,
cancellationToken: cancellationToken
) )
); );
} }
@@ -315,7 +335,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
WHERE BreweryPostID = @BreweryPostId WHERE BreweryPostID = @BreweryPostId
""", """,
new { brewery.BreweryPostId }, new { brewery.BreweryPostId },
transaction transaction,
cancellationToken: cancellationToken
) )
) )
is not null; is not null;
@@ -346,7 +367,8 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Longitude = brewery.Location.Coordinates?.Longitude, Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction transaction,
cancellationToken: cancellationToken
) )
); );
else else
@@ -385,12 +407,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Longitude = brewery.Location.Coordinates?.Longitude, Longitude = brewery.Location.Coordinates?.Longitude,
Srid = GeographySrid, Srid = GeographySrid,
}, },
transaction transaction,
cancellationToken: cancellationToken
) )
); );
} }
await transaction.CommitAsync(); await transaction.CommitAsync(cancellationToken);
} }
catch catch
{ {
@@ -398,7 +421,7 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
throw; throw;
} }
return await GetByIdAsync(brewery.BreweryPostId) return await GetByIdAsync(brewery.BreweryPostId, cancellationToken)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"Brewery '{brewery.BreweryPostId}' was not found after a successful update." $"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" />, /// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
/// <see cref="City" />, <see cref="StateProvince" /> and <see cref="Country" /> fragments into a /// <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 /// 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 /// brewery with no address on file), Dapper passes <see langword="null" /> for
/// from all-<c>NULL</c> columns; the schema guarantees that whenever a location row was joined /// <paramref name="location" /> rather than a blank instance, since its identity column
/// in, its City/StateProvince/Country chain was too. /// (<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> /// </summary>
private static BreweryPost MapBreweryRow( private static BreweryPost MapBreweryRow(
BreweryPost post, BreweryPost post,
BreweryPostLocation location, BreweryPostLocation? location,
City city, City? city,
StateProvince stateProvince, StateProvince? stateProvince,
Country country Country? country
) )
{ {
if (location.BreweryPostLocationId == Guid.Empty) if (location is null)
return post; 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; stateProvince.Country = country;
city.StateProvinceId = stateProvince.StateProvinceId;
city.StateProvince = stateProvince; city.StateProvince = stateProvince;
location.BreweryPostId = post.BreweryPostId; 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 /// 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. /// exists with the given ID, or if it has no associated location.
/// </summary> /// </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> /// <summary>
/// Retrieves all brewery posts, optionally paginated, ordered by creation date descending. Posts /// 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 /// 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). /// modified by another request since it was last read).
/// </exception> /// </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> /// <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> /// <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.DeleteAccount;
using Features.Auth.Commands.Account.UpdateEmail; using Features.Auth.Commands.Account.UpdateEmail;
using Features.Auth.Commands.Account.UpdatePassword; using Features.Auth.Commands.Account.UpdatePassword;
@@ -31,18 +29,6 @@ namespace Features.Auth.Controllers;
[Authorize(AuthenticationSchemes = "JWT")] [Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase 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> /// <summary>Registers a new user account.</summary>
/// <remarks> /// <remarks>
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens. /// 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( UpdateUsernamePayload payload = await mediator.Send(
new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername) new UpdateUsernameCommand(User.GetAuthenticatedUserId(), request.NewUsername)
); );
return Ok( return Ok(
new ResponseBody<UpdateUsernamePayload> new ResponseBody<UpdateUsernamePayload>
@@ -172,7 +158,7 @@ public class AuthController(IMediator mediator) : ControllerBase
) )
{ {
UpdateEmailPayload payload = await mediator.Send( UpdateEmailPayload payload = await mediator.Send(
new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail) new UpdateEmailCommand(User.GetAuthenticatedUserId(), request.NewEmail)
); );
return Ok( return Ok(
new ResponseBody<UpdateEmailPayload> new ResponseBody<UpdateEmailPayload>
@@ -195,7 +181,7 @@ public class AuthController(IMediator mediator) : ControllerBase
{ {
UpdatePasswordPayload payload = await mediator.Send( UpdatePasswordPayload payload = await mediator.Send(
new UpdatePasswordCommand( new UpdatePasswordCommand(
GetAuthenticatedUserId(), User.GetAuthenticatedUserId(),
request.CurrentPassword, request.CurrentPassword,
request.NewPassword request.NewPassword
) )
@@ -218,7 +204,7 @@ public class AuthController(IMediator mediator) : ControllerBase
{ {
UpdateProfilePayload payload = await mediator.Send( UpdateProfilePayload payload = await mediator.Send(
new UpdateProfileCommand( new UpdateProfileCommand(
GetAuthenticatedUserId(), User.GetAuthenticatedUserId(),
request.FirstName, request.FirstName,
request.LastName, request.LastName,
request.DateOfBirth request.DateOfBirth
@@ -241,7 +227,7 @@ public class AuthController(IMediator mediator) : ControllerBase
[HttpDelete("account")] [HttpDelete("account")]
public async Task<ActionResult<ResponseBody>> DeleteAccount() 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." }); 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> <Nullable>enable</Nullable>
<RootNamespace>Shared.Contracts</RootNamespace> <RootNamespace>Shared.Contracts</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
</ItemGroup>
</Project> </Project>