Add authenticated user profile page with locked-down account endpoint (#315)

This commit is contained in:
Aaron Po
2026-09-23 00:58:54 -04:00
committed by GitHub
parent a6a9046457
commit 0ff42c3c70
45 changed files with 1418 additions and 80 deletions

View File

@@ -0,0 +1,66 @@
Feature: Public User Profile
As a client of the API
I want to read a user's public-facing profile
So that a public profile page can render without exposing private account fields
Background:
Given the API is running
Scenario: Public profile exposes only allowlisted fields
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the public profile response should match the registered account
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Public profile for a non-existent account returns not found
Given I have registered a new account
And I have a valid access token for my account
When I retrieve the public profile by a non-existent ID
Then the response has HTTP status 404
Scenario: Fetching the public profile without authentication succeeds
Given I have registered a new account
When I retrieve the public profile by ID without authentication
Then the response has HTTP status 200
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: A different user can read someone else's public profile
Given I have registered a new account
And I am logged in as a different user
When I retrieve the public profile by ID
Then the response has HTTP status 200
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: Fetching a user account by ID without authentication is rejected
When I retrieve the user account by a non-existent ID without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts without authentication is rejected
When I list user accounts without authentication
Then the response has HTTP status 401
Scenario: Listing user accounts as an authenticated user excludes private fields
Given I have registered a new account
And I have a valid access token for my account
When I list user accounts
Then the response has HTTP status 200
And the response JSON should not contain "email"
And the response JSON should not contain "dateOfBirth"
Scenario: The owner can fetch their own account by ID
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
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
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

View File

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

View File

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

View File

@@ -262,46 +262,6 @@ public class BrewerySteps(ScenarioContext scenario) : ApiStepsBase(scenario)
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)
{

View File

@@ -0,0 +1,95 @@
using System.Text.Json;
using FluentAssertions;
using Reqnroll;
namespace API.Specs.Steps;
[Binding]
public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
{
private const string AccessTokenKey = "accessToken";
private const string RegisteredUserIdKey = "registeredUserId";
private const string RegisteredUsernameKey = "registeredUsername";
private async Task SendAsync(HttpRequestMessage requestMessage)
{
HttpClient client = GetClient();
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
Scenario[ResponseKey] = response;
Scenario[ResponseBodyKey] = responseBody;
}
private HttpRequestMessage NewAuthenticatedRequest(HttpMethod method, string url)
{
HttpRequestMessage requestMessage = new(method, url);
if (Scenario.TryGetValue<string>(AccessTokenKey, out string? accessToken))
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
return requestMessage;
}
private Guid GetRegisteredUserId()
{
return Scenario.TryGetValue(RegisteredUserIdKey, out Guid id)
? id
: throw new InvalidOperationException("registered user ID not found in scenario");
}
[When("I retrieve the public profile by ID")]
public async Task WhenIRetrieveThePublicProfileById()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
}
[When("I retrieve the public profile by ID without authentication")]
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
}
[When("I retrieve the public profile by a non-existent ID")]
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile"));
}
[When("I retrieve the user account by a non-existent ID without authentication")]
public async Task WhenIRetrieveTheUserAccountByANonExistentIdWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}"));
}
[When("I list user accounts without authentication")]
public async Task WhenIListUserAccountsWithoutAuthentication()
{
await SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/user"));
}
[When("I list user accounts")]
public async Task WhenIListUserAccounts()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, "/api/user"));
}
[When("I retrieve the registered account by ID")]
public async Task WhenIRetrieveTheRegisteredAccountById()
{
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}"));
}
[Then("the public profile response should match the registered account")]
public void ThenThePublicProfileResponseShouldMatchTheRegisteredAccount()
{
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
string? username = Scenario.TryGetValue<string>(RegisteredUsernameKey, out string? u)
? u
: throw new InvalidOperationException("registered username not found in scenario");
using JsonDocument doc = JsonDocument.Parse(responseBody!);
JsonElement root = doc.RootElement;
root.GetProperty("userAccountId").GetGuid().Should().Be(GetRegisteredUserId());
root.GetProperty("username").GetString().Should().Be(username);
}
}

View File

@@ -1,4 +1,5 @@
using Domain.Entities;
using Features.Users.Dtos;
using Features.Users.Queries.GetAllUsers;
using Features.Users.Repository;
using FluentAssertions;
@@ -15,7 +16,7 @@ public class GetAllUsersHandlerTests
GetAllUsersHandler handler = new(repoMock.Object);
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
IEnumerable<UserAccount> result = await handler.Handle(
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
new GetAllUsersQuery(10, 5),
CancellationToken.None
);
@@ -23,4 +24,27 @@ public class GetAllUsersHandlerTests
result.Should().BeEmpty();
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
}
[Fact]
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
{
Mock<IUserListRepository> repoMock = new();
GetAllUsersHandler handler = new(repoMock.Object);
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "someone",
FirstName = "Some",
LastName = "One",
CreatedAt = DateTime.UtcNow,
};
repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
new GetAllUsersQuery(null, null),
CancellationToken.None
);
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
}
}

View File

@@ -0,0 +1,65 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Users.Dtos;
using Features.Users.Queries.GetPublicUserProfileById;
using Features.Users.Repository;
using FluentAssertions;
using Moq;
namespace Features.Users.Tests.Queries;
public class GetPublicUserProfileByIdHandlerTests
{
private readonly GetPublicUserProfileByIdHandler _handler;
private readonly Mock<IUserListRepository> _repoMock = new();
public GetPublicUserProfileByIdHandlerTests()
{
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
}
[Fact]
public async Task Handle_ReturnsPublicProfile_WhenFound()
{
UserAccount user = new()
{
UserAccountId = Guid.NewGuid(),
Username = "test",
FirstName = "Jane",
LastName = "Doe",
Email = "jane@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
CreatedAt = new DateTime(2024, 1, 1),
};
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
PublicUserProfileDto result = await _handler.Handle(
new GetPublicUserProfileByIdQuery(user.UserAccountId),
CancellationToken.None
);
result
.Should()
.Be(
new PublicUserProfileDto(
user.UserAccountId,
user.Username,
user.FirstName,
user.LastName,
user.CreatedAt
)
);
}
[Fact]
public async Task Handle_Throws_WhenNotFound()
{
Guid id = Guid.NewGuid();
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
Func<Task<PublicUserProfileDto>> act = async () =>
await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@@ -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),
new GetUserByIdQuery(user.UserAccountId, user.UserAccountId),
CancellationToken.None
);
@@ -38,8 +38,21 @@ public class GetUserByIdHandlerTests
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
Func<Task<UserAccount>> act = async () =>
await _handler.Handle(new GetUserByIdQuery(id), CancellationToken.None);
await _handler.Handle(new GetUserByIdQuery(id, 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

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

View File

@@ -0,0 +1,11 @@
using Domain.Entities;
namespace Features.Users.Dtos;
/// <summary>Converts user domain entities into API response models.</summary>
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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,12 +8,18 @@ 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,4 +3,9 @@ using MediatR;
namespace Features.Users.Queries.GetUserById;
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
/// <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>
public record GetUserByIdQuery(Guid UserAccountId, Guid RequestingUserId) : IRequest<UserAccount>;

View File

@@ -33,13 +33,15 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
/// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is consumed only as public
/// profiles, so those fields are left at their default rather than read from the database.
/// </remarks>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion
FROM Auth.UserAccount
ORDER BY CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY