diff --git a/playground/Stress/Stress.AppHost/InteractionCommands.cs b/playground/Stress/Stress.AppHost/InteractionCommands.cs index 9eac262b492..5815d51b189 100644 --- a/playground/Stress/Stress.AppHost/InteractionCommands.cs +++ b/playground/Stress/Stress.AppHost/InteractionCommands.cs @@ -127,8 +127,8 @@ public static IResourceBuilder AddInteractionCommands(this IResourceBuilde var numberOfPeopleInput = new InteractionInput { InputType = InputType.Number, Label = "Number of people", Placeholder = "Enter number of people", Value = "2", Required = true }; var inputs = new List { - new InteractionInput { InputType = InputType.Text, Label = "Name", Placeholder = "Enter name", Required = true }, - new InteractionInput { InputType = InputType.SecretText, Label = "Password", Placeholder = "Enter password", Required = true }, + new InteractionInput { InputType = InputType.Text, Label = "Name", Placeholder = "Enter name", Required = true, MaxLength = 50 }, + new InteractionInput { InputType = InputType.SecretText, Label = "Password", Placeholder = "Enter password", Required = true, MaxLength = 20 }, dinnerInput, numberOfPeopleInput, new InteractionInput { InputType = InputType.Boolean, Label = "Remember me", Placeholder = "What does this do?", Required = true }, diff --git a/src/Aspire.Dashboard/Aspire.Dashboard.csproj b/src/Aspire.Dashboard/Aspire.Dashboard.csproj index fb5c2753ea0..7375dcc90f1 100644 --- a/src/Aspire.Dashboard/Aspire.Dashboard.csproj +++ b/src/Aspire.Dashboard/Aspire.Dashboard.csproj @@ -270,7 +270,8 @@ - + + diff --git a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor index 34c40a57c0f..1de27d0dcb1 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor @@ -33,10 +33,6 @@ @foreach (var vm in _inputDialogInputViewModels) { - @* - * AutoComplete value of one-time-code on password input prevents the browser asking to save the value. - * Immediate value of true on text inputs ensures the value is set to the server token with every key press in textbox. - *@ var localItem = vm; var descriptionId = !string.IsNullOrEmpty(localItem.Input.Description) ? $"{_elementRefs[localItem]?.Id}-description" @@ -45,17 +41,25 @@ @switch (vm.Input.InputType) { case InputType.Text: + @* + * Immediate value of true on text input ensures the value is set to the server token with every key press in textbox. + *@ + aria-describedby="@descriptionId" + Maxlength="@InteractionHelpers.GetMaxLength(localItem.Input.MaxLength)" /> @GetDescriptionContent(localItem.Input, descriptionId) break; case InputType.SecretText: + @* + * AutoComplete value of one-time-code on password input prevents the browser asking to save the value. + * Immediate value of true on text input ensures the value is set to the server token with every key press in textbox. + *@ + aria-describedby="@descriptionId" + Maxlength="@InteractionHelpers.GetMaxLength(localItem.Input.MaxLength)" /> @GetDescriptionContent(localItem.Input, descriptionId) break; diff --git a/src/Aspire.Hosting/Aspire.Hosting.csproj b/src/Aspire.Hosting/Aspire.Hosting.csproj index f4c4a6192ec..bd5b57f8de8 100644 --- a/src/Aspire.Hosting/Aspire.Hosting.csproj +++ b/src/Aspire.Hosting/Aspire.Hosting.csproj @@ -42,6 +42,7 @@ + diff --git a/src/Aspire.Hosting/Dashboard/DashboardService.cs b/src/Aspire.Hosting/Dashboard/DashboardService.cs index bae38ad46e6..025af451b58 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardService.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardService.cs @@ -143,6 +143,10 @@ async Task WatchInteractionsInternal(CancellationToken cancellationToken) { dto.Options.Add(input.Options.ToDictionary()); } + if (input.MaxLength != null) + { + dto.MaxLength = input.MaxLength.Value; + } dto.ValidationErrors.AddRange(input.ValidationErrors); return dto; }).ToList(); diff --git a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto index a507b564cf6..5705667f3ef 100644 --- a/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto +++ b/src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto @@ -376,6 +376,7 @@ message InteractionInput { repeated string validation_errors = 7; string description = 8; bool enable_description_markdown = 9; + int32 max_length = 10; } enum MessageIntent { MESSAGE_INTENT_NONE = 0; diff --git a/src/Aspire.Hosting/IInteractionService.cs b/src/Aspire.Hosting/IInteractionService.cs index 5aac1e80d23..1d22163ca8a 100644 --- a/src/Aspire.Hosting/IInteractionService.cs +++ b/src/Aspire.Hosting/IInteractionService.cs @@ -144,6 +144,23 @@ public sealed class InteractionInput /// public string? Placeholder { get; set; } + /// + /// gets or sets the maximum length for text inputs. + /// + public int? MaxLength + { + get => field; + set + { + if (value is { } v) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(v, 0); + } + + field = value; + } + } + internal List ValidationErrors { get; } = []; } diff --git a/src/Aspire.Hosting/InteractionService.cs b/src/Aspire.Hosting/InteractionService.cs index ac5850eef9e..69f20fc2561 100644 --- a/src/Aspire.Hosting/InteractionService.cs +++ b/src/Aspire.Hosting/InteractionService.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; +using System.Globalization; using System.Runtime.CompilerServices; using System.Threading.Channels; using Microsoft.Extensions.Logging; @@ -234,25 +235,77 @@ private async Task RunValidationAsync(Interaction interactionState, Intera // State could be null if the user dismissed the inputs dialog. There is nothing to validate in this situation. if (result.State is IReadOnlyList inputs) { - var options = (InputsDialogInteractionOptions)interactionState.Options; + foreach (var input in inputs) + { + input.ValidationErrors.Clear(); + } + + var context = new InputsDialogValidationContext + { + CancellationToken = cancellationToken, + ServiceProvider = _serviceProvider, + Inputs = inputsInfo.Inputs + }; - if (options.ValidationCallback is { } validationCallback) + foreach (var input in inputs) { - foreach (var input in inputs) + var value = input.Value = input.Value?.Trim(); + + if (string.IsNullOrEmpty(value)) { - input.ValidationErrors.Clear(); + if (input.Required) + { + context.AddValidationError(input, "Value is required."); + } } - - var context = new InputsDialogValidationContext + else { - CancellationToken = cancellationToken, - ServiceProvider = _serviceProvider, - Inputs = inputsInfo.Inputs - }; - await validationCallback(context).ConfigureAwait(false); + switch (input.InputType) + { + case InputType.Text: + case InputType.SecretText: + var maxLength = InteractionHelpers.GetMaxLength(input.MaxLength); + + if (value.Length > maxLength) + { + context.AddValidationError(input, $"Value length exceeds {maxLength} characters."); + } + break; + case InputType.Choice: + if (!input.Options?.Any(o => o.Key == value) ?? true) + { + context.AddValidationError(input, "Value must be one of the provided options."); + } + break; + case InputType.Boolean: + if (!bool.TryParse(value, out _)) + { + context.AddValidationError(input, "Value must be a valid boolean."); + } + break; + case InputType.Number: + if (!int.TryParse(value, CultureInfo.InvariantCulture, out _)) + { + context.AddValidationError(input, "Value must be a valid number."); + } + break; + default: + break; + } + } + } - return !context.HasErrors; + // Only run validation callback if there are no data validation errors. + if (!context.HasErrors) + { + var options = (InputsDialogInteractionOptions)interactionState.Options; + if (options.ValidationCallback is { } validationCallback) + { + await validationCallback(context).ConfigureAwait(false); + } } + + return !context.HasErrors; } } diff --git a/src/Shared/InteractionHelpers.cs b/src/Shared/InteractionHelpers.cs new file mode 100644 index 00000000000..2e764327bc7 --- /dev/null +++ b/src/Shared/InteractionHelpers.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; + +namespace Aspire; + +internal static class InteractionHelpers +{ + // Chosen to balance between being long enough for most normal use but provides a default limit + // to prevent possible abuse of interactions API. + public const int DefaultMaxLength = 8000; + + public static int GetMaxLength(int? configuredInputLength) + { + // An unconfigured max length uses the default. + if (configuredInputLength is null || configuredInputLength == 0) + { + return DefaultMaxLength; + } + + return configuredInputLength.Value; + } +} diff --git a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs index 2190bfa2195..2c6488d4d69 100644 --- a/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs +++ b/tests/Aspire.Hosting.Tests/InteractionServiceTests.cs @@ -172,7 +172,7 @@ await Assert.ThrowsAsync( } [Fact] - public async Task PromptInputAsync_InvalidData() + public async Task PromptInputAsync_ValidationCallbackInvalidData_ReturnErrors() { var interactionService = CreateInteractionService(); @@ -194,10 +194,132 @@ public async Task PromptInputAsync_InvalidData() Assert.False(interaction.CompletionTcs.Task.IsCompleted); Assert.Equal(Interaction.InteractionState.InProgress, interaction.State); - await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new [] { input } }); + await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new[] { input } }); // The interaction should still be in progress due to validation error Assert.False(interaction.CompletionTcs.Task.IsCompleted); + + Assert.Collection( + input.ValidationErrors, + error => Assert.Equal("Invalid value", error)); + } + + [Fact] + public async Task PromptInputsAsync_MissingRequiredData_ReturnErrors() + { + var interactionService = CreateInteractionService(); + + var input = new InteractionInput { Label = "Value", InputType = InputType.Text, Required = true }; + var resultTask = interactionService.PromptInputAsync("Please provide", "please", input); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + + await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new[] { input } }); + + // The interaction should still be in progress due to invalid data + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + + Assert.Collection(input.ValidationErrors, + error => Assert.Equal("Value is required.", error)); + } + + [Fact] + public async Task PromptInputsAsync_ChoiceHasNonOptionValue_ReturnErrors() + { + var interactionService = CreateInteractionService(); + + var input = new InteractionInput { Label = "Value", InputType = InputType.Choice, Options = [KeyValuePair.Create("first", "First option!"), KeyValuePair.Create("second", "Second option!")] }; + var resultTask = interactionService.PromptInputAsync("Please provide", "please", input); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + + input.Value = "not-in-options"; + await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new[] { input } }); + + // The interaction should still be in progress due to invalid data + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + + Assert.Collection(input.ValidationErrors, + error => Assert.Equal("Value must be one of the provided options.", error)); + } + + [Fact] + public async Task PromptInputsAsync_NumberHasNonNumberValue_ReturnErrors() + { + var interactionService = CreateInteractionService(); + + var input = new InteractionInput { Label = "Value", InputType = InputType.Number }; + var resultTask = interactionService.PromptInputAsync("Please provide", "please", input); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + + input.Value = "one"; + await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new[] { input } }); + + // The interaction should still be in progress due to invalid data + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + + Assert.Collection(input.ValidationErrors, + error => Assert.Equal("Value must be a valid number.", error)); + } + + [Fact] + public async Task PromptInputsAsync_BooleanHasNonBooleanValue_ReturnErrors() + { + var interactionService = CreateInteractionService(); + + var input = new InteractionInput { Label = "Value", InputType = InputType.Boolean }; + var resultTask = interactionService.PromptInputAsync("Please provide", "please", input); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + + input.Value = "maybe"; + await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new[] { input } }); + + // The interaction should still be in progress due to invalid data + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + + Assert.Collection(input.ValidationErrors, + error => Assert.Equal("Value must be a valid boolean.", error)); + } + + [Theory] + [InlineData(InputType.Text, null)] + [InlineData(InputType.Text, 1)] + [InlineData(InputType.Text, 10)] + [InlineData(InputType.Text, InteractionHelpers.DefaultMaxLength)] + [InlineData(InputType.SecretText, 10)] + public async Task PromptInputsAsync_TextExceedsLimit_ReturnErrors(InputType inputType, int? maxLength) + { + await TextExceedsLimitCoreAsync(inputType, maxLength, success: true); + await TextExceedsLimitCoreAsync(inputType, maxLength, success: false); + + static async Task TextExceedsLimitCoreAsync(InputType inputType, int? maxLength, bool success) + { + var interactionService = CreateInteractionService(); + + var input = new InteractionInput { Label = "Value", InputType = inputType, MaxLength = maxLength }; + var resultTask = interactionService.PromptInputAsync("Please provide", "please", input); + + var interaction = Assert.Single(interactionService.GetCurrentInteractions()); + var resolvedMaxLength = InteractionHelpers.GetMaxLength(maxLength); + + input.Value = new string('!', success ? resolvedMaxLength : resolvedMaxLength + 1); + await CompleteInteractionAsync(interactionService, interaction.InteractionId, new InteractionCompletionState { Complete = true, State = new[] { input } }); + + if (!success) + { + // The interaction should still be in progress due to invalid data + Assert.False(interaction.CompletionTcs.Task.IsCompleted); + + Assert.Collection(input.ValidationErrors, + error => Assert.Equal($"Value length exceeds {resolvedMaxLength} characters.", error)); + } + else + { + Assert.True(interaction.CompletionTcs.Task.IsCompletedSuccessfully); + } + } } [Fact] @@ -253,6 +375,36 @@ public void InteractionInput_WithNullDescription_AllowsNullValue() Assert.False(input.EnableDescriptionMarkdown); } + [Theory] + [InlineData(null, false)] + [InlineData(1, false)] + [InlineData(int.MaxValue, false)] + [InlineData(0, true)] + [InlineData(-1, true)] + [InlineData(int.MinValue, true)] + public void InteractionInput_WithLengths_ErrorOnInvalid(int? length, bool invalid) + { + // Arrange & Act + if (invalid) + { + Assert.Throws(() => SetLength(length)); + } + else + { + SetLength(length); + } + + static void SetLength(int? length) + { + var input = new InteractionInput + { + Label = "Test Label", + InputType = InputType.Text, + MaxLength = length + }; + } + } + private static async Task CompleteInteractionAsync(InteractionService interactionService, int interactionId, InteractionCompletionState state) { await interactionService.CompleteInteractionAsync(interactionId, (_, _) => state, CancellationToken.None);