Features/public biography endpoint (#316)

This commit is contained in:
Aaron Po
2026-09-26 19:51:10 -04:00
committed by GitHub
parent e1b6cba1d4
commit c5972b9f04
20 changed files with 158 additions and 104 deletions

View File

@@ -12,6 +12,7 @@ Scenario: Public profile exposes only allowlisted fields
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should match the registered account
And the response JSON should have "biography" equal ""
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
@@ -36,8 +37,8 @@ Scenario: A different user can read someone else's public profile
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Fetching a user account by ID without authentication is rejected
When I retrieve the user account by a non-existent ID without authentication
Scenario: Fetching my own account without authentication is rejected
When I retrieve my own account without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts without authentication is rejected
@@ -52,15 +53,16 @@ Scenario: Listing user accounts as an authenticated user excludes private fields
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: The owner can fetch their own account by ID
Scenario: The caller can fetch their own account
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the registered account by ID
When I retrieve my own account
Then the response has HTTP status 200
And the public profile response should match the registered account
Scenario: A different user cannot fetch someone else's account by ID
Scenario: The account endpoint serves the caller named by the access token
Given I have registered a new account
And I am logged in as a different user
When I retrieve the registered account by ID
Then the response has HTTP status 403
When I retrieve my own account
Then the response has HTTP status 200
And the account response should be for a different account than the registered one

View File

@@ -54,10 +54,10 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile"));
}
[When("I retrieve the user account by a non-existent ID without authentication")]
public async Task WhenIRetrieveTheUserAccountByANonExistentIdWithoutAuthentication()
[When("I retrieve my own account without authentication")]
public async Task WhenIRetrieveMyOwnAccountWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}"));
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user/authenticated"));
}
[When("I list user accounts without authentication")]
@@ -72,10 +72,10 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
}
[When("I retrieve the registered account by ID")]
public async Task WhenIRetrieveTheRegisteredAccountById()
[When("I retrieve my own account")]
public async Task WhenIRetrieveMyOwnAccount()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}"));
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user/authenticated"));
}
[Then("the public profile response should match the registered account")]
@@ -92,4 +92,16 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
root.GetProperty("username").GetString().Should().Be(username);
}
[Then("the account response should be for a different account than the registered one")]
public void ThenTheAccountResponseShouldBeForADifferentAccountThanTheRegisteredOne()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
using JsonDocument doc = JsonDocument.Parse(responseBody!);
doc.RootElement.GetProperty("userAccountId")
.GetGuid()
.Should()
.NotBe(GetRegisteredUserId());
}
}

View File

@@ -19,4 +19,5 @@ public sealed class UserAccount
// Navigation properties
public UserCredential? UserCredential { get; set; }
public UserProfile? UserProfile { get; set; }
}

View File

@@ -12,10 +12,6 @@
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
<ItemGroup>

View File

@@ -30,6 +30,7 @@ public class GetPublicUserProfileByIdHandlerTests
Email = "jane@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1),
UserProfile = new UserProfile { Biography = "Drinks lagers." },
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
@@ -46,11 +47,33 @@ public class GetPublicUserProfileByIdHandlerTests
user.Username,
user.FirstName,
user.LastName,
"Drinks lagers.",
user.CreatedAt
)
);
}
[Fact]
public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
CreatedAt = new DateTime(2024, 1, 1),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result.Biography.Should().BeEmpty();
}
[Fact]
public async Task Handle_Throws_WhenNotFound()
{

View File

@@ -24,7 +24,7 @@ public class GetUserByIdHandlerTests
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
UserAccount result = await _handler.Handle(
new GetUserByIdQuery(user.UserAccountId, user.UserAccountId),
new GetUserByIdQuery(user.UserAccountId),
CancellationToken.None
);
@@ -38,21 +38,8 @@ public class GetUserByIdHandlerTests
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
Func<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id, id), CancellationToken.None);
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task Handle_Throws_WhenRequestingSomeoneElsesAccount()
{
Guid id = Guid.NewGuid();
Guid requestingUserId = Guid.NewGuid();
Func<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id, requestingUserId), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
_repoMock.Verify(r => r.GetByIdAsync(It.IsAny<Guid>()), Times.Never);
}
}

View File

@@ -17,9 +17,10 @@ namespace Features.Users.Controllers;
/// <remarks>
/// Every endpoint requires <c>[Authorize(AuthenticationSchemes = "JWT")]</c> except
/// <see cref="GetPublicProfile" />, which is <see cref="AllowAnonymousAttribute" /> since it
/// only returns an allowlisted DTO. <see cref="GetById" /> also restricts access to the
/// caller's own account. Neither <see cref="GetAll" /> nor <see cref="GetById" /> may expose
/// another user's <c>Email</c> or <c>DateOfBirth</c>.
/// only returns an allowlisted DTO. <see cref="GetAuthenticated" /> serves the caller's own
/// account, taken from the access token. Neither <see cref="GetAll" /> nor
/// <see cref="GetPublicProfile" /> may expose another user's <c>Email</c> or
/// <c>DateOfBirth</c>.
/// </remarks>
[ApiController]
[Route("api/[controller]")]
@@ -41,18 +42,15 @@ public class UserController(IMediator mediator) : ControllerBase
return Ok(users);
}
/// <summary>Gets the caller's own user account by ID.</summary>
/// <exception cref="ForbiddenException">
/// Thrown when <paramref name="id"/> is not the caller's own account. The API returns HTTP status 403.
/// </exception>
/// <summary>Gets the authenticated caller's own user account.</summary>
/// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
/// Thrown when no user account exists for the caller's ID, resulting in a 404 response.
/// </exception>
[HttpGet("{id:guid}")]
public async Task<ActionResult<UserAccount>> GetById(Guid id)
[HttpGet("authenticated")]
public async Task<ActionResult<UserAccount>> GetAuthenticated()
{
UserAccount user = await mediator.Send(
new GetUserByIdQuery(id, User.GetAuthenticatedUserId())
new GetUserByIdQuery(User.GetAuthenticatedUserId())
);
return Ok(user);
}
@@ -61,7 +59,7 @@ public class UserController(IMediator mediator) : ControllerBase
/// <remarks>
/// This endpoint is anonymous-accessible and requires no ownership. Anyone can view any
/// account's public profile. The response includes only fields that are safe to show publicly.
/// Unlike <see cref="GetById"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>.
/// Unlike <see cref="GetAuthenticated"/>, it never includes <c>Email</c> or <c>DateOfBirth</c>.
/// </remarks>
/// <exception cref="NotFoundException">
/// Thrown when no user account exists with the given ID. The API returns HTTP status 404.

View File

@@ -6,6 +6,15 @@ namespace Features.Users.Dtos;
public static class UserDtoMapper
{
/// <summary>Converts a user account into its public-profile representation.</summary>
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) =>
new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt);
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user)
{
return new PublicUserProfileDto(
UserAccountId : user.UserAccountId,
Username : user.Username,
FirstName : user.FirstName,
LastName : user.LastName,
Biography : user.UserProfile?.Biography ?? string.Empty,
CreatedAt : user.CreatedAt
);
}
}

View File

@@ -6,5 +6,6 @@ public record PublicUserProfileDto(
string Username,
string FirstName,
string LastName,
string Biography,
DateTime CreatedAt
);

View File

@@ -8,18 +8,12 @@ namespace Features.Users.Queries.GetUserById;
public class GetUserByIdHandler(IUserListRepository repository)
: IRequestHandler<GetUserByIdQuery, UserAccount>
{
/// <exception cref="ForbiddenException">
/// Thrown when the caller requests an account other than their own.
/// </exception>
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
public async Task<UserAccount> Handle(
GetUserByIdQuery request,
CancellationToken cancellationToken
)
{
if (request.UserAccountId != request.RequestingUserId)
throw new ForbiddenException("You are not authorized to view this account.");
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
if (user is null)
throw new NotFoundException($"User with ID {request.UserAccountId} not found");

View File

@@ -3,9 +3,7 @@ using MediatR;
namespace Features.Users.Queries.GetUserById;
/// <param name="UserAccountId">The account to fetch.</param>
/// <param name="RequestingUserId">
/// The authenticated caller's ID, from the access token. It must equal
/// <paramref name="UserAccountId" />. This query returns private fields, including <c>Email</c>.
/// <param name="UserAccountId">
/// The account to fetch. This query returns private fields, including <c>Email</c>.
/// </param>
public record GetUserByIdQuery(Guid UserAccountId, Guid RequestingUserId) : IRequest<UserAccount>;
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;

View File

@@ -13,20 +13,41 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
IUserListRepository
{
/// <inheritdoc />
/// <remarks>
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
/// <c>null</c> for an account whose profile has yet to be created.
/// </remarks>
public async Task<UserAccount?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
IEnumerable<UserAccount> results = await connection.QueryAsync<
UserAccount,
UserProfile,
UserAccount
>(
"""
SELECT
UserAccountID,
Username,
FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM Auth.UserAccount
WHERE UserAccountID = @UserAccountId
ua.UserAccountID,
ua.Username,
ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt,
ua.DateOfBirth, ua.RowVersion,
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion
FROM Auth.UserAccount ua
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
WHERE ua.UserAccountID = @UserAccountId
""",
new { UserAccountId = id }
MapUserRow,
new { UserAccountId = id },
splitOn: "UserProfileID"
);
return results.SingleOrDefault();
}
private static UserAccount MapUserRow(UserAccount user, UserProfile? profile)
{
user.UserProfile = profile;
return user;
}
/// <inheritdoc />