From fe0f7ea5aa4e872fe096ec04d7f4a14925637eb9 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 12:43:36 +0200 Subject: [PATCH 1/3] test(server): send a NUL or a lone surrogate to each text value The domain checks only the length of a text value. Postgres text cannot hold U+0000, and UTF-8 cannot encode a lone surrogate, so the save fails and the API gives 500. These tests fail until the domain refuses both: - StringColumnLengthTests writes U+0000, U+D800, and U+DC00 to each caller column and expects a ValidationException on the request field. - CharacterPostgresCannotStoreTests sends a NUL in each text field of each write endpoint over HTTP and expects 422 on the field. - The gRPC AddTenant and MCP update_tenant tests expect an invalid argument on the name. --- .../Tenants/TenantGrpcTests.cs | 20 +++ .../Tenants/TenantMcpToolTests.cs | 24 +++ .../CharacterPostgresCannotStoreTests.cs | 146 ++++++++++++++++++ .../TestUtilities/ApiRoutes.cs | 1 + .../Databases/StringColumnLengthTests.cs | 22 +++ 5 files changed, 213 insertions(+) create mode 100644 tests/FullstackTemplate.FunctionalTests/FunctionalTests/TextValues/CharacterPostgresCannotStoreTests.cs diff --git a/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantGrpcTests.cs b/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantGrpcTests.cs index bd66ef4e..d2ae8f99 100644 --- a/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantGrpcTests.cs +++ b/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantGrpcTests.cs @@ -183,6 +183,26 @@ public async Task empty_tenant_name_is_an_invalid_argument_with_a_field_violatio violation.Description.ShouldBe("Please provide a tenant name."); } + [Test] + public async Task nul_in_a_tenant_name_is_an_invalid_argument_with_a_field_violation() + { + // Arrange + SignInAsTrustedMachineClient(); + using var channel = CreateGrpcChannel(); + var client = new TenantsService.TenantsServiceClient(channel); + + // Act + var exception = await Should.ThrowAsync(async () => + await client.AddTenantAsync(new AddTenantRequest { Name = $"Tenant {Guid.NewGuid()}\0" })); + + // Assert + exception.StatusCode.ShouldBe(StatusCode.InvalidArgument); + var violation = exception.GetRpcStatus().ShouldNotBeNull().GetDetail().ShouldNotBeNull() + .FieldViolations.ShouldHaveSingleItem(); + violation.Field.ShouldBe("name"); + violation.Description.ShouldBe("A tenant name has a character that is not allowed."); + } + [Test] public async Task call_with_no_token_is_unauthenticated() { diff --git a/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantMcpToolTests.cs b/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantMcpToolTests.cs index f2ba4bba..85922bc8 100644 --- a/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantMcpToolTests.cs +++ b/tests/FullstackTemplate.FunctionalTests/FunctionalTests/Tenants/TenantMcpToolTests.cs @@ -325,6 +325,30 @@ public async Task admin_can_rename_their_tenant() (await FindAsync(tenant.Id)).ShouldNotBeNull().Name.ShouldBe(name); } + [Test] + public async Task rename_with_a_nul_in_the_name_is_an_invalid_argument_with_the_field() + { + // Arrange + var (tenant, admin) = await AddTenantWithUserAsync("Admin"); + SignInAs(admin); + await using var client = await CreateMcpClientAsync(); + + // Act + var result = await client.CallToolAsync("update_tenant", + new Dictionary + { + ["id"] = tenant.Id, + ["name"] = $"Renamed {Guid.NewGuid()}\0", + ["version"] = await VersionOfAsync(tenant.Id) + }); + + // Assert + ShouldBeToolError(result, "invalid_argument"); + result.StructuredContent.ShouldNotBeNull().GetProperty("errors").GetProperty("name")[0].GetString() + .ShouldBe("A tenant name has a character that is not allowed."); + (await FindAsync(tenant.Id)).ShouldNotBeNull().Name.ShouldBe(tenant.Name); + } + [Test] public async Task rename_with_a_stale_version_is_the_aborted_tool_error() { diff --git a/tests/FullstackTemplate.FunctionalTests/FunctionalTests/TextValues/CharacterPostgresCannotStoreTests.cs b/tests/FullstackTemplate.FunctionalTests/FunctionalTests/TextValues/CharacterPostgresCannotStoreTests.cs new file mode 100644 index 00000000..fb06c029 --- /dev/null +++ b/tests/FullstackTemplate.FunctionalTests/FunctionalTests/TextValues/CharacterPostgresCannotStoreTests.cs @@ -0,0 +1,146 @@ +namespace FullstackTemplate.FunctionalTests.FunctionalTests.TextValues; + +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FullstackTemplate.Server.Domain.Tenants.Dtos; +using FullstackTemplate.SharedTestHelpers.EntityGraphs; +using FullstackTemplate.SharedTestHelpers.Fakes.Agent; +using FullstackTemplate.SharedTestHelpers.Fakes.Tenant; +using FullstackTemplate.SharedTestHelpers.Fakes.User; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using TestUtilities; + +public class CharacterPostgresCannotStoreTests : TestBase +{ + private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + [Test] + public async Task a_nul_in_a_first_name_returns_a_validation_problem_for_the_field_and_saves_no_user() + { + // Arrange + TestGraph.Start(out _).AddTenant(out var tenant).AddUser(out var admin, u => u.Role = "Admin"); + await InsertAsync(tenant); + await InsertAsync(admin); + FactoryClient.SignInAsAppUser(admin.Identifier); + var dto = new FakeUserForCreationDto().Generate(); + var body = Json(dto); + body["firstName"] = "Ada\0"; + + // Act + var result = await FactoryClient.PostAsync(ApiRoutes.Users.Create, JsonContent.Create(body)); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + result.Content.Headers.ContentType?.MediaType.ShouldBe("application/problem+json"); + var problem = (await result.Content.ReadFromJsonAsync()).ShouldNotBeNull(); + problem.Status.ShouldBe(StatusCodes.Status422UnprocessableEntity); + problem.Detail.ShouldBe("A first name has a character that is not allowed."); + problem.Errors.ShouldHaveSingleItem().Key.ShouldBe("firstName"); + problem.Errors["firstName"].ShouldBe(["A first name has a character that is not allowed."]); + var saved = await ExecuteDbContextAsync(db => db.Users.IgnoreQueryFilters().AnyAsync(u => u.Identifier == dto.Identifier)); + saved.ShouldBeFalse(); + } + + public static IEnumerable<(string, string)> WriteEndpointTextFields => + [ + ("add user", "firstName"), + ("add user", "lastName"), + ("add user", "identifier"), + ("add user", "email"), + ("add user", "username"), + ("update user", "firstName"), + ("update user", "lastName"), + ("update user", "email"), + ("update user", "username"), + ("initiate user and tenant", "tenantName"), + ("initiate user and tenant", "firstName"), + ("initiate user and tenant", "lastName"), + ("initiate user and tenant", "identifier"), + ("initiate user and tenant", "email"), + ("initiate user and tenant", "username"), + ("add tenant", "name"), + ("update tenant", "name"), + ("add agent", "name"), + ("add agent", "clientId"), + ]; + + [Test] + [MethodDataSource(nameof(WriteEndpointTextFields))] + public async Task a_nul_in_a_text_field_of_a_write_endpoint_returns_a_validation_problem_for_the_field(string endpoint, string field) + { + // Arrange + var (method, url, body) = await ArrangeAsync(endpoint); + body[field] = field == "email" ? "a\0b@example.com" : "a\0b"; + + // Act + var result = await FactoryClient.SendAsync(new HttpRequestMessage(method, url) { Content = JsonContent.Create(body) }); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var problem = (await result.Content.ReadFromJsonAsync()).ShouldNotBeNull(); + problem.Errors.ShouldHaveSingleItem().Key.ShouldBe(field); + problem.Detail.ShouldNotBeNull().ShouldEndWith("has a character that is not allowed."); + } + + [Test] + [Arguments("add user")] + [Arguments("add agent")] + public async Task a_nul_in_the_tenant_id_returns_a_validation_problem_for_the_field(string endpoint) + { + // Arrange + var (method, url, body) = await ArrangeAsync(endpoint); + SignInAsTrustedMachineClient(); + body["tenantId"] = "ten_\0"; + + // Act + var result = await FactoryClient.SendAsync(new HttpRequestMessage(method, url) { Content = JsonContent.Create(body) }); + + // Assert + result.StatusCode.ShouldBe(HttpStatusCode.UnprocessableEntity); + var problem = (await result.Content.ReadFromJsonAsync()).ShouldNotBeNull(); + problem.Errors.ShouldHaveSingleItem().Key.ShouldBe("tenantId"); + } + + private async Task<(HttpMethod Method, string Url, JsonObject Body)> ArrangeAsync(string endpoint) + { + TestGraph.Start(out _).AddTenant(out var tenant).AddUser(out var admin, u => u.Role = "Admin"); + await InsertAsync(tenant); + await InsertAsync(admin); + FactoryClient.SignInAsAppUser(admin.Identifier); + + switch (endpoint) + { + case "add user": + return (HttpMethod.Post, ApiRoutes.Users.Create, Json(new FakeUserForCreationDto().Generate())); + case "update user": + return (HttpMethod.Put, ApiRoutes.Users.Put(admin.Id), + Json(new FakeUserForUpdateDto().Generate() with { Version = admin.Version })); + case "initiate user and tenant": + SignInAsTrustedMachineClient(); + return (HttpMethod.Post, ApiRoutes.Users.Initiate, Json(new FakeInitiateUserAndTenantDto().Generate())); + case "add tenant": + SignInAsTrustedMachineClient(); + return (HttpMethod.Post, ApiRoutes.Tenants.Create, Json(new FakeTenantForCreationDto().Generate())); + case "update tenant": + return (HttpMethod.Put, ApiRoutes.Tenants.Put(tenant.Id), + Json(new TenantForUpdateDto { Name = $"Tenant {Guid.NewGuid()}", Version = tenant.Version })); + case "add agent": + return (HttpMethod.Post, ApiRoutes.Agents.Create, Json(new FakeAgentForCreationDto().Generate())); + default: + throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, null); + } + } + + private void SignInAsTrustedMachineClient() + => FactoryClient.SetFakeBearerToken(new Dictionary + { + ["client_id"] = TestingWebApplicationFactory.TrustedMachineClientId + }); + + private static JsonObject Json(T dto) => JsonSerializer.SerializeToNode(dto, CamelCase)!.AsObject(); +} diff --git a/tests/FullstackTemplate.FunctionalTests/TestUtilities/ApiRoutes.cs b/tests/FullstackTemplate.FunctionalTests/TestUtilities/ApiRoutes.cs index c14660f8..a6e4b2c7 100644 --- a/tests/FullstackTemplate.FunctionalTests/TestUtilities/ApiRoutes.cs +++ b/tests/FullstackTemplate.FunctionalTests/TestUtilities/ApiRoutes.cs @@ -32,6 +32,7 @@ public static class Tenants public static class Agents { public static string GetList => $"{Base}/agents"; + public static string Create => $"{Base}/agents"; public static string GetRecord(string id) => $"{Base}/agents/{id}"; public static string AddPermission(string id) => $"{Base}/agents/{id}/permissions"; public static string RemovePermission(string id, string permission) => $"{Base}/agents/{id}/permissions/{permission}"; diff --git a/tests/FullstackTemplate.UnitTests/Databases/StringColumnLengthTests.cs b/tests/FullstackTemplate.UnitTests/Databases/StringColumnLengthTests.cs index 0f85b2c0..6c5c8928 100644 --- a/tests/FullstackTemplate.UnitTests/Databases/StringColumnLengthTests.cs +++ b/tests/FullstackTemplate.UnitTests/Databases/StringColumnLengthTests.cs @@ -121,6 +121,28 @@ public void the_domain_refuses_a_too_long_value_of_lone_surrogates_with_a_valida writeTooLong.ShouldThrow().Property.ShouldBe(callerColumn.RequestField); } + private static readonly string[] CharactersPostgresCannotStore = ["\0", "\uD800", "\uDC00"]; + + public static IEnumerable<(string, string)> CallerColumnsAndCharactersPostgresCannotStore => + CallerColumns.Keys.SelectMany(column => CharactersPostgresCannotStore.Select(character => (column, character))); + + [Test] + [MethodDataSource(nameof(CallerColumnsAndCharactersPostgresCannotStore))] + public void the_domain_refuses_a_character_that_postgres_cannot_store_with_a_validation_error(string column, string character) + { + // Arrange + var callerColumn = CallerColumns[column]; + var value = callerColumn.ValueOfLength("a", 20).Insert(1, character); + + // Act + var write = () => callerColumn.Write(value); + + // Assert + var exception = write.ShouldThrow(); + exception.Property.ShouldBe(callerColumn.RequestField); + exception.Message.ShouldContain("a character that is not allowed"); + } + [Test] public void each_option_of_a_fixed_option_column_fits_the_column() { From 9d742f4a1569ea74b8f3067bc0657e9508fb3962 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 12:50:44 +0200 Subject: [PATCH 2/3] fix(server): reject a NUL or a lone surrogate in a text value with a 422 Postgres text cannot hold U+0000, and UTF-8 cannot encode a lone surrogate. The domain checked only the length of a text value, so such a value got to Postgres and the API gave 500. ValidationException.ThrowWhenHasCharacterPostgresCannotStore refuses both. Each text value that a request sets calls it: the first name, last name, identifier, and username of a user, the email, the tenant name, and the agent name and client ID. Other control characters stay allowed, because Postgres stores them. AddTenant, AddUser, and InitiateUserAndTenant ran their unique-value queries with the raw request value before the entity validated it, so the query failed first. They now create the entity first and query with its value. UpdateTenant now also queries with the value of the entity. AddUser and AddAgent sent the requested tenant ID to a query before any check. A caller that manages all tenants could send a NUL there. An ID that is not a valid tenant ID now gets "The tenant does not exist." without a query. --- .agents/skills/backend-feature/SKILL.md | 2 +- .../Domain/Agents/Agent.cs | 4 +++ .../Agents/Features/AddAgent/AddAgent.cs | 7 ++-- .../Domain/EmailAddresses/EmailAddress.cs | 2 ++ .../Tenants/Features/AddTenant/AddTenant.cs | 7 ++-- .../Features/UpdateTenant/UpdateTenant.cs | 2 +- .../Domain/Tenants/Tenant.cs | 2 ++ .../Domain/Users/Features/AddUser/AddUser.cs | 14 ++++---- .../InitiateUserAndTenant.cs | 33 ++++++++++--------- FullstackTemplate.Server/Domain/Users/User.cs | 8 +++++ .../Exceptions/ValidationException.cs | 14 ++++++++ docs/backend/domain-entities.md | 4 ++- docs/backend/features-and-cqrs.md | 1 + 13 files changed, 69 insertions(+), 31 deletions(-) diff --git a/.agents/skills/backend-feature/SKILL.md b/.agents/skills/backend-feature/SKILL.md index dcfbf259..6f36f6a5 100644 --- a/.agents/skills/backend-feature/SKILL.md +++ b/.agents/skills/backend-feature/SKILL.md @@ -396,7 +396,7 @@ public LineItem AddLineItem(Product product, int quantity) - [ ] Verify entity configuration is in `Databases/EntityConfigurations/` and properly applied - [ ] Each foreign-key column to an entity ID has `HasMaxLength(EntityId.MaxLength)` and `UseCollation(EntityId.Collation)` - [ ] Each foreign key to an entity ID has a prefix check with `EntityId.IsValid` in the entity validation -- [ ] Each text value that a request sets has a max length constant that the entity validation checks and `HasMaxLength` uses (see `docs/backend/domain-entities.md`) +- [ ] Each text value that a request sets has a max length constant that the entity validation checks and `HasMaxLength` uses, and the entity validation refuses a character that Postgres cannot store with `ThrowWhenHasCharacterPostgresCannotStore` (see `docs/backend/domain-entities.md`) - [ ] Install MediatR if not already present - [ ] Install Mapperly if not already present - [ ] Run `dotnet build` to verify compilation diff --git a/FullstackTemplate.Server/Domain/Agents/Agent.cs b/FullstackTemplate.Server/Domain/Agents/Agent.cs index b567c4ae..4efa7669 100644 --- a/FullstackTemplate.Server/Domain/Agents/Agent.cs +++ b/FullstackTemplate.Server/Domain/Agents/Agent.cs @@ -112,9 +112,13 @@ private static void ValidateAgent(Agent agent) ValidationException.ThrowWhenNullOrWhitespace(agent.Name, "name", "Please provide a name."); ValidationException.ThrowWhenLongerThan(agent.Name, NameMaxLength, "name", $"A name can have no more than {NameMaxLength} characters."); + ValidationException.ThrowWhenHasCharacterPostgresCannotStore(agent.Name, "name", + "A name has a character that is not allowed."); ValidationException.ThrowWhenNullOrWhitespace(agent.ClientId, "clientId", "Please provide a client ID."); ValidationException.ThrowWhenLongerThan(agent.ClientId, ClientIdMaxLength, "clientId", $"A client ID can have no more than {ClientIdMaxLength} characters."); + ValidationException.ThrowWhenHasCharacterPostgresCannotStore(agent.ClientId, "clientId", + "A client ID has a character that is not allowed."); ValidationException.Must(agent.ClientId == agent.ClientId.Trim(), "clientId", "A client ID cannot start or end with a space."); } diff --git a/FullstackTemplate.Server/Domain/Agents/Features/AddAgent/AddAgent.cs b/FullstackTemplate.Server/Domain/Agents/Features/AddAgent/AddAgent.cs index 4dadd2c1..358d9c0f 100644 --- a/FullstackTemplate.Server/Domain/Agents/Features/AddAgent/AddAgent.cs +++ b/FullstackTemplate.Server/Domain/Agents/Features/AddAgent/AddAgent.cs @@ -82,9 +82,10 @@ private async Task ResolveTenantIdAsync(string? requestedTenantId, Cance if (!scope.Includes(requestedTenantId)) throw new ForbiddenAccessException("You can add an agent only to your own tenant."); - var tenantExists = await dbContext.Tenants - .TagWithOperationCallSite("agents.add.tenant_exists") - .AnyAsync(t => t.Id == requestedTenantId, cancellationToken); + var tenantExists = EntityId.IsValid(requestedTenantId, Tenants.Tenant.IdPrefix) + && await dbContext.Tenants + .TagWithOperationCallSite("agents.add.tenant_exists") + .AnyAsync(t => t.Id == requestedTenantId, cancellationToken); if (!tenantExists) throw new ValidationException(TenantIdField, "The tenant does not exist."); diff --git a/FullstackTemplate.Server/Domain/EmailAddresses/EmailAddress.cs b/FullstackTemplate.Server/Domain/EmailAddresses/EmailAddress.cs index 0cdb23be..a5e5fc52 100644 --- a/FullstackTemplate.Server/Domain/EmailAddresses/EmailAddress.cs +++ b/FullstackTemplate.Server/Domain/EmailAddresses/EmailAddress.cs @@ -13,6 +13,8 @@ public EmailAddress(string value) Exceptions.ValidationException.ThrowWhenNullOrWhitespace(value, "email", "Please provide a valid email."); Exceptions.ValidationException.ThrowWhenLongerThan(value, MaxLength, "email", $"An email can have no more than {MaxLength} characters."); + Exceptions.ValidationException.ThrowWhenHasCharacterPostgresCannotStore(value, "email", + "An email has a character that is not allowed."); new EmailValidator().ValidateAndThrow(value); Value = value; } diff --git a/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs b/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs index c235c772..18c65c63 100644 --- a/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs +++ b/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs @@ -30,16 +30,17 @@ public async Task Handle(Command request, CancellationToken cancellat throw new ForbiddenAccessException( "Only a trusted tenant management client can add a tenant. A user starts their own tenant with POST /api/v1/users/initiate."); + var forCreation = request.Dto.ToTenantForCreation(); + var tenant = Tenant.Create(forCreation); + // Ignore all filters: the unique index on Name also covers deleted tenants. var nameInUse = await dbContext.Tenants .TagWithOperationCallSite("tenants.add.name_in_use") .IgnoreQueryFilters() - .AnyAsync(t => t.Name == request.Dto.Name, cancellationToken); + .AnyAsync(t => t.Name == tenant.Name, cancellationToken); if (nameInUse) throw new ConflictException("name", "A tenant with this name already exists."); - var forCreation = request.Dto.ToTenantForCreation(); - var tenant = Tenant.Create(forCreation); Activity.Current?.SetTag("app.tenant_id", tenant.Id); await dbContext.Tenants.AddAsync(tenant, cancellationToken); diff --git a/FullstackTemplate.Server/Domain/Tenants/Features/UpdateTenant/UpdateTenant.cs b/FullstackTemplate.Server/Domain/Tenants/Features/UpdateTenant/UpdateTenant.cs index 68d50b87..81885f1e 100644 --- a/FullstackTemplate.Server/Domain/Tenants/Features/UpdateTenant/UpdateTenant.cs +++ b/FullstackTemplate.Server/Domain/Tenants/Features/UpdateTenant/UpdateTenant.cs @@ -37,7 +37,7 @@ public async Task Handle(Command request, CancellationToken cancellat var nameInUse = await dbContext.Tenants .TagWithOperationCallSite("tenants.update.name_in_use") .IgnoreQueryFilters() - .AnyAsync(t => t.Id != tenant.Id && t.Name == request.Dto.Name, cancellationToken); + .AnyAsync(t => t.Id != tenant.Id && t.Name == tenant.Name, cancellationToken); if (nameInUse) throw new ConflictException("name", "A tenant with this name already exists."); diff --git a/FullstackTemplate.Server/Domain/Tenants/Tenant.cs b/FullstackTemplate.Server/Domain/Tenants/Tenant.cs index 60a0b94e..da2fed28 100644 --- a/FullstackTemplate.Server/Domain/Tenants/Tenant.cs +++ b/FullstackTemplate.Server/Domain/Tenants/Tenant.cs @@ -41,6 +41,8 @@ private static void ValidateTenant(Tenant tenant) Exceptions.ValidationException.ThrowWhenNullOrWhitespace(tenant.Name, "name", "Please provide a tenant name."); Exceptions.ValidationException.ThrowWhenLongerThan(tenant.Name, NameMaxLength, "name", $"A tenant name can have no more than {NameMaxLength} characters."); + Exceptions.ValidationException.ThrowWhenHasCharacterPostgresCannotStore(tenant.Name, "name", + "A tenant name has a character that is not allowed."); } protected Tenant() : base(IdPrefix) { } // For EF Core diff --git a/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs b/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs index 19159c5a..baf469d2 100644 --- a/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs +++ b/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs @@ -35,16 +35,17 @@ public async Task Handle(Command request, CancellationToken cancellatio var tenantId = await ResolveTenantIdAsync(request.Dto.TenantId, cancellationToken); Activity.Current?.SetTag("app.tenant_id", tenantId); + var forCreation = request.Dto.ToUserForCreation(tenantId); + var user = User.Create(forCreation); + // Ignore the tenant filter: the unique index on Identifier covers the live users of all tenants. var identifierInUse = await dbContext.Users .TagWithOperationCallSite("users.add.identifier_in_use") .IgnoreQueryFilters([QueryFilterNames.Tenant]) - .AnyAsync(u => u.Identifier == request.Dto.Identifier, cancellationToken); + .AnyAsync(u => u.Identifier == user.Identifier, cancellationToken); if (identifierInUse) throw new ConflictException("identifier", "A user with this identifier already exists."); - var forCreation = request.Dto.ToUserForCreation(tenantId); - var user = User.Create(forCreation); Activity.Current?.SetTag("app.user_id", user.Id); await dbContext.Users.AddAsync(user, cancellationToken); @@ -75,9 +76,10 @@ private async Task ResolveTenantIdAsync(string? requestedTenantId, Cance throw new ForbiddenAccessException("You can add a user only to your own tenant."); // The tenant filter and the soft delete filter apply, so a deleted tenant does not exist here. - var tenantExists = await dbContext.Tenants - .TagWithOperationCallSite("users.add.tenant_exists") - .AnyAsync(t => t.Id == requestedTenantId, cancellationToken); + var tenantExists = EntityId.IsValid(requestedTenantId, Tenants.Tenant.IdPrefix) + && await dbContext.Tenants + .TagWithOperationCallSite("users.add.tenant_exists") + .AnyAsync(t => t.Id == requestedTenantId, cancellationToken); if (!tenantExists) throw new ValidationException(TenantIdField, "The tenant does not exist."); diff --git a/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs b/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs index e1afafa6..6181001e 100644 --- a/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs +++ b/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs @@ -33,22 +33,6 @@ public async Task Handle(Command request, Canc { await GuardCallerCanInitiateForAsync(request.Dto.Identifier, cancellationToken); - // Ignore the tenant filter: the unique index on Identifier covers the live users of all tenants. - var identifierInUse = await dbContext.Users - .TagWithOperationCallSite("users.initiate.identifier_in_use") - .IgnoreQueryFilters([QueryFilterNames.Tenant]) - .AnyAsync(u => u.Identifier == request.Dto.Identifier, cancellationToken); - if (identifierInUse) - throw new ConflictException("identifier", "A user with this identifier already exists."); - - // Ignore all filters: the unique index on Name also covers deleted tenants. - var tenantNameInUse = await dbContext.Tenants - .TagWithOperationCallSite("users.initiate.tenant_name_in_use") - .IgnoreQueryFilters() - .AnyAsync(t => t.Name == request.Dto.TenantName, cancellationToken); - if (tenantNameInUse) - throw new ConflictException(TenantNameField, TenantNameTaken); - // Create tenant first var tenantForCreation = new TenantForCreation { Name = request.Dto.TenantName }; Tenant tenant; @@ -76,6 +60,23 @@ public async Task Handle(Command request, Canc Role = request.Dto.Role }; var user = User.Create(userForCreation); + + // Ignore the tenant filter: the unique index on Identifier covers the live users of all tenants. + var identifierInUse = await dbContext.Users + .TagWithOperationCallSite("users.initiate.identifier_in_use") + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .AnyAsync(u => u.Identifier == user.Identifier, cancellationToken); + if (identifierInUse) + throw new ConflictException("identifier", "A user with this identifier already exists."); + + // Ignore all filters: the unique index on Name also covers deleted tenants. + var tenantNameInUse = await dbContext.Tenants + .TagWithOperationCallSite("users.initiate.tenant_name_in_use") + .IgnoreQueryFilters() + .AnyAsync(t => t.Name == tenant.Name, cancellationToken); + if (tenantNameInUse) + throw new ConflictException(TenantNameField, TenantNameTaken); + Activity.Current?.SetTag("app.user_id", user.Id); await dbContext.Users.AddAsync(user, cancellationToken); diff --git a/FullstackTemplate.Server/Domain/Users/User.cs b/FullstackTemplate.Server/Domain/Users/User.cs index 1c8b57f9..329c98ae 100644 --- a/FullstackTemplate.Server/Domain/Users/User.cs +++ b/FullstackTemplate.Server/Domain/Users/User.cs @@ -137,15 +137,23 @@ private static void ValidateUser(User user) Exceptions.ValidationException.ThrowWhenNullOrWhitespace(user.FirstName, "firstName", "Please provide a first name."); Exceptions.ValidationException.ThrowWhenLongerThan(user.FirstName, FirstNameMaxLength, "firstName", $"A first name can have no more than {FirstNameMaxLength} characters."); + Exceptions.ValidationException.ThrowWhenHasCharacterPostgresCannotStore(user.FirstName, "firstName", + "A first name has a character that is not allowed."); Exceptions.ValidationException.ThrowWhenNullOrWhitespace(user.LastName, "lastName", "Please provide a last name."); Exceptions.ValidationException.ThrowWhenLongerThan(user.LastName, LastNameMaxLength, "lastName", $"A last name can have no more than {LastNameMaxLength} characters."); + Exceptions.ValidationException.ThrowWhenHasCharacterPostgresCannotStore(user.LastName, "lastName", + "A last name has a character that is not allowed."); Exceptions.ValidationException.ThrowWhenNullOrWhitespace(user.Identifier, "identifier", "Please provide an identifier."); Exceptions.ValidationException.ThrowWhenLongerThan(user.Identifier, IdentifierMaxLength, "identifier", $"An identifier can have no more than {IdentifierMaxLength} characters."); + Exceptions.ValidationException.ThrowWhenHasCharacterPostgresCannotStore(user.Identifier, "identifier", + "An identifier has a character that is not allowed."); Exceptions.ValidationException.ThrowWhenNullOrWhitespace(user.Username, "username", "Please provide a username."); Exceptions.ValidationException.ThrowWhenLongerThan(user.Username, UsernameMaxLength, "username", $"A username can have no more than {UsernameMaxLength} characters."); + Exceptions.ValidationException.ThrowWhenHasCharacterPostgresCannotStore(user.Username, "username", + "A username has a character that is not allowed."); Exceptions.ValidationException.ThrowWhenNull(user.Role, "role", "Please provide a role."); } diff --git a/FullstackTemplate.Server/Exceptions/ValidationException.cs b/FullstackTemplate.Server/Exceptions/ValidationException.cs index 17c83787..dbf99df8 100644 --- a/FullstackTemplate.Server/Exceptions/ValidationException.cs +++ b/FullstackTemplate.Server/Exceptions/ValidationException.cs @@ -1,5 +1,8 @@ namespace FullstackTemplate.Server.Exceptions; +using System.Buffers; +using System.Text; + /// /// Exception thrown when domain validation rules are violated. /// @@ -45,6 +48,17 @@ public static void ThrowWhenLongerThan(string value, int maxLength, string prope throw new ValidationException(property, message); } + public static void ThrowWhenHasCharacterPostgresCannotStore(string value, string property, string message) + { + var rest = value.AsSpan(); + while (!rest.IsEmpty) + { + if (Rune.DecodeFromUtf16(rest, out var rune, out var consumed) != OperationStatus.Done || rune.Value == 0) + throw new ValidationException(property, message); + rest = rest[consumed..]; + } + } + public static void Must(bool condition, string property, string message) { if (!condition) diff --git a/docs/backend/domain-entities.md b/docs/backend/domain-entities.md index ff1ac525..1aa23b14 100644 --- a/docs/backend/domain-entities.md +++ b/docs/backend/domain-entities.md @@ -13,7 +13,7 @@ A domain entity holds its data and its business rules. Code outside the entity c 7. Use a value object for a domain concept, such as an email or a role. Do not use a bare `string`. 8. In a method that applies the fields of an edit form, call `GuardVersion` first. See [Versions](#versions). 9. Give each property `[Sensitive]` or `[NotSensitive]`, except a flag, an enum, a `Guid`, an integer, or a foreign key. The audit log shows a sensitive value only to a reader with `read_audit_log_sensitive`. See [audit-log.md](audit-log.md#rules). -10. Give each text value that a request sets a max length constant, for example `User.FirstNameMaxLength` or `EmailAddress.MaxLength`. Refuse a longer value with `ValidationException.ThrowWhenLongerThan`, and put the limit in its message. It counts characters as Postgres does. Do not compare `.Length`, because it counts an emoji as two. Use the same constant in `HasMaxLength`. Without the check, a long value fails the save in Postgres, and the API gives 500, not 422. `StringColumnLengthTests` fails on each text column with a max length that is not a key or a foreign key, also one stored through `HasConversion`, until you list it as a caller column with its domain write, a fixed-option column, or a server column. +10. Give each text value that a request sets a max length constant, for example `User.FirstNameMaxLength` or `EmailAddress.MaxLength`. Refuse a longer value with `ValidationException.ThrowWhenLongerThan`, and put the limit in its message. It counts characters as Postgres does. Do not compare `.Length`, because it counts an emoji as two. Use the same constant in `HasMaxLength`. Without the check, a long value fails the save in Postgres, and the API gives 500, not 422. Also refuse a value with a character that Postgres cannot store, U+0000 or a lone surrogate, with `ValidationException.ThrowWhenHasCharacterPostgresCannotStore`. Its message says that the value has a character that is not allowed. Other control characters, such as a tab or a line feed, are allowed, because Postgres stores them. `StringColumnLengthTests` fails on each text column with a max length that is not a key or a foreign key, also one stored through `HasConversion`, until you list it as a caller column with its domain write, a fixed-option column, or a server column. It also fails when the domain write of a caller column takes U+0000 or a lone surrogate. ## IDs @@ -70,6 +70,8 @@ public class User : BaseEntity, ITenantable ValidationException.ThrowWhenNullOrWhitespace(user.FirstName, "firstName", "Please provide a first name."); ValidationException.ThrowWhenLongerThan(user.FirstName, FirstNameMaxLength, "firstName", $"A first name can have no more than {FirstNameMaxLength} characters."); + ValidationException.ThrowWhenHasCharacterPostgresCannotStore(user.FirstName, "firstName", + "A first name has a character that is not allowed."); ValidationException.Must(EntityId.IsValid(user.TenantId, Tenant.IdPrefix), "tenantId", "Please provide a valid tenant ID."); } diff --git a/docs/backend/features-and-cqrs.md b/docs/backend/features-and-cqrs.md index 629bda57..c6df0b3d 100644 --- a/docs/backend/features-and-cqrs.md +++ b/docs/backend/features-and-cqrs.md @@ -20,6 +20,7 @@ No MediatR behavior runs FluentValidation validators. Do not add a `Validator` c - Put the rules for one entity in the entity. See [domain-entities.md](domain-entities.md). - Put a check that needs the database, such as a unique value, in the handler. Throw a `ConflictException` on the request field, which gives 409. See [backend.md](../backend.md#error-responses). +- Create or change the entity before the check that needs the database, and query with the value of the entity. Then the entity refuses a value that Postgres cannot take, such as a text with U+0000, with a 422 before a query sends it. A query that gets such a value fails, and the API gives 500. When a check must run before the entity exists, such as the lookup of the tenant ID of the request, query only when `EntityId.IsValid` passes for the ID. ## Command From 6ebbac2f79d38d4d6b98c19814e93b8c58cb8faa Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 13:15:52 +0200 Subject: [PATCH 3/3] fix(server): tag the new id before the taken check and test the characters that stay allowed --- .../Tenants/Features/AddTenant/AddTenant.cs | 3 +- .../Domain/Users/Features/AddUser/AddUser.cs | 3 +- .../InitiateUserAndTenant.cs | 2 +- .../CharacterPostgresCannotStoreTests.cs | 42 +++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 tests/FullstackTemplate.UnitTests/Exceptions/CharacterPostgresCannotStoreTests.cs diff --git a/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs b/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs index 18c65c63..c98540c5 100644 --- a/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs +++ b/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs @@ -32,6 +32,7 @@ public async Task Handle(Command request, CancellationToken cancellat var forCreation = request.Dto.ToTenantForCreation(); var tenant = Tenant.Create(forCreation); + Activity.Current?.SetTag("app.tenant_id", tenant.Id); // Ignore all filters: the unique index on Name also covers deleted tenants. var nameInUse = await dbContext.Tenants @@ -41,8 +42,6 @@ public async Task Handle(Command request, CancellationToken cancellat if (nameInUse) throw new ConflictException("name", "A tenant with this name already exists."); - Activity.Current?.SetTag("app.tenant_id", tenant.Id); - await dbContext.Tenants.AddAsync(tenant, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); diff --git a/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs b/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs index baf469d2..b65efd09 100644 --- a/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs +++ b/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs @@ -37,6 +37,7 @@ public async Task Handle(Command request, CancellationToken cancellatio var forCreation = request.Dto.ToUserForCreation(tenantId); var user = User.Create(forCreation); + Activity.Current?.SetTag("app.user_id", user.Id); // Ignore the tenant filter: the unique index on Identifier covers the live users of all tenants. var identifierInUse = await dbContext.Users @@ -46,8 +47,6 @@ public async Task Handle(Command request, CancellationToken cancellatio if (identifierInUse) throw new ConflictException("identifier", "A user with this identifier already exists."); - Activity.Current?.SetTag("app.user_id", user.Id); - await dbContext.Users.AddAsync(user, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); diff --git a/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs b/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs index 6181001e..4d970a6e 100644 --- a/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs +++ b/FullstackTemplate.Server/Domain/Users/Features/InitiateUserAndTenant/InitiateUserAndTenant.cs @@ -60,6 +60,7 @@ public async Task Handle(Command request, Canc Role = request.Dto.Role }; var user = User.Create(userForCreation); + Activity.Current?.SetTag("app.user_id", user.Id); // Ignore the tenant filter: the unique index on Identifier covers the live users of all tenants. var identifierInUse = await dbContext.Users @@ -77,7 +78,6 @@ public async Task Handle(Command request, Canc if (tenantNameInUse) throw new ConflictException(TenantNameField, TenantNameTaken); - Activity.Current?.SetTag("app.user_id", user.Id); await dbContext.Users.AddAsync(user, cancellationToken); // Single transaction - both saved together diff --git a/tests/FullstackTemplate.UnitTests/Exceptions/CharacterPostgresCannotStoreTests.cs b/tests/FullstackTemplate.UnitTests/Exceptions/CharacterPostgresCannotStoreTests.cs new file mode 100644 index 00000000..22bd68c0 --- /dev/null +++ b/tests/FullstackTemplate.UnitTests/Exceptions/CharacterPostgresCannotStoreTests.cs @@ -0,0 +1,42 @@ +namespace FullstackTemplate.UnitTests.Exceptions; + +using FullstackTemplate.Server.Exceptions; +using Shouldly; + +public class CharacterPostgresCannotStoreTests +{ + [Test] + [Arguments("")] + [Arguments("a\u0001b")] + [Arguments("a\tb")] + [Arguments("a\nb")] + [Arguments("a\u007Fb")] + [Arguments("a\u0085b")] + [Arguments("a￿b")] + [Arguments("a😀b")] + [Arguments("a😀")] + public void a_value_that_postgres_can_store_passes(string value) + { + // Act + var check = () => ValidationException.ThrowWhenHasCharacterPostgresCannotStore(value, "name", "Not allowed."); + + // Assert + check.ShouldNotThrow(); + } + + [Test] + [Arguments("\0")] + [Arguments("a\0")] + [Arguments("a\uD800")] + [Arguments("\uDC00a")] + [Arguments("a\uDE00\uD83Db")] + [Arguments("a\uD83D😀")] + public void a_value_with_a_nul_or_a_lone_surrogate_fails(string value) + { + // Act + var check = () => ValidationException.ThrowWhenHasCharacterPostgresCannotStore(value, "name", "Not allowed."); + + // Assert + check.ShouldThrow().Property.ShouldBe("name"); + } +}