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,43 +39,59 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
Country, Country,
BreweryPost BreweryPost
>( >(
""" new CommandDefinition(
SELECT """
bp.BreweryPostID, SELECT
bp.PostedByID, bp.BreweryPostID,
bp.BreweryName, bp.PostedByID,
bp.Description, bp.BreweryName,
bp.CreatedAt, bp.Description,
bp.UpdatedAt, bp.CreatedAt,
bp.RowVersion, bp.UpdatedAt,
bpl.BreweryPostLocationID, bp.RowVersion,
bpl.AddressLine1, bpl.BreweryPostLocationID,
bpl.AddressLine2, bpl.AddressLine1,
bpl.PostalCode, bpl.AddressLine2,
CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, bpl.PostalCode,
c.CityID, CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates,
c.CityName, c.CityID,
sp.StateProvinceID, c.CityName,
sp.StateProvinceName, sp.StateProvinceID,
sp.ISO3166_2 AS Iso31662, sp.StateProvinceName,
co.CountryID, sp.ISO3166_2 AS Iso31662,
co.CountryName, co.CountryID,
co.ISO3166_1 AS Iso31661 co.CountryName,
FROM dbo.BreweryPost bp co.ISO3166_1 AS Iso31661
LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID FROM dbo.BreweryPost bp
LEFT JOIN dbo.City c ON bpl.CityID = c.CityID LEFT JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID
LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID LEFT JOIN dbo.City c ON bpl.CityID = c.CityID
LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID
WHERE bp.BreweryPostID = @BreweryPostId LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID
""", WHERE bp.BreweryPostID = @BreweryPostId
""",
new { BreweryPostId = id },
cancellationToken: cancellationToken
),
MapBreweryRow, MapBreweryRow,
new { BreweryPostId = id },
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>