Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/backend-feature/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions FullstackTemplate.Server/Domain/Agents/Agent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ private async Task<string> 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.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,18 @@ public async Task<TenantDto> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public async Task<TenantDto> 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.");

Expand Down
2 changes: 2 additions & 0 deletions FullstackTemplate.Server/Domain/Tenants/Tenant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,18 @@ public async Task<UserDto> 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);

Expand Down Expand Up @@ -75,9 +75,10 @@ private async Task<string> 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.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,6 @@ public async Task<InitiateUserAndTenantResponseDto> 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;
Expand Down Expand Up @@ -77,6 +61,23 @@ public async Task<InitiateUserAndTenantResponseDto> 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
Expand Down
8 changes: 8 additions & 0 deletions FullstackTemplate.Server/Domain/Users/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}

Expand Down
14 changes: 14 additions & 0 deletions FullstackTemplate.Server/Exceptions/ValidationException.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
namespace FullstackTemplate.Server.Exceptions;

using System.Buffers;
using System.Text;

/// <summary>
/// Exception thrown when domain validation rules are violated.
/// </summary>
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion docs/backend/domain-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.");
}

Expand Down
1 change: 1 addition & 0 deletions docs/backend/features-and-cqrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RpcException>(async () =>
await client.AddTenantAsync(new AddTenantRequest { Name = $"Tenant {Guid.NewGuid()}\0" }));

// Assert
exception.StatusCode.ShouldBe(StatusCode.InvalidArgument);
var violation = exception.GetRpcStatus().ShouldNotBeNull().GetDetail<BadRequest>().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()
{
Expand Down
Loading
Loading