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..c98540c5 100644 --- a/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs +++ b/FullstackTemplate.Server/Domain/Tenants/Features/AddTenant/AddTenant.cs @@ -30,18 +30,18 @@ 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); + 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 .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); await dbContext.SaveChangesAsync(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..b65efd09 100644 --- a/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs +++ b/FullstackTemplate.Server/Domain/Users/Features/AddUser/AddUser.cs @@ -35,18 +35,18 @@ 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); + 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 .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); await dbContext.SaveChangesAsync(cancellationToken); @@ -75,9 +75,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..4d970a6e 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; @@ -77,6 +61,23 @@ public async Task Handle(Command request, Canc }; 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 + .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); + await dbContext.Users.AddAsync(user, cancellationToken); // Single transaction - both saved together 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 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() { 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"); + } +}