// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only
using System.Text;
using System.Threading.Channels;
using Aspire.DashboardService.Proto.V1;
using Aspire.Hosting.Dashboard;
using Aspire.Hosting.Tests.Helpers;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Tests.Utils.Grpc;
using Aspire.Hosting.Utils;
using Aspire.Shared.ConsoleLogs;
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using DashboardServiceImpl = Aspire.Hosting.Dashboard.DashboardService;
using Resource = Aspire.Hosting.ApplicationModel.Resource;
namespace Aspire.Hosting.Tests.Dashboard;
[Trait("Partition", "3")]
public class DashboardServiceTests(ITestOutputHelper testOutputHelper)
{
[Fact]
public async Task WatchResourceConsoleLogs_NoFollow_ResultsEnd()
{
// Arrange
const int LongLineCharacters = DashboardServiceImpl.LogMaxBatchCharacters / 3;
var getConsoleLogsChannel = Channel.CreateUnbounded<IReadOnlyList<LogEntry>>();
var consoleLogsService = new TestConsoleLogsService(name => getConsoleLogsChannel);
var resourceLoggerService = new ResourceLoggerService();
resourceLoggerService.SetConsoleLogsService(consoleLogsService);
var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
var dashboardService = CreateDashboardService(dashboardServiceData);
var logger = resourceLoggerService.GetLogger("test-resource");
// Three long lines
logger.LogInformation(new string('1', LongLineCharacters));
logger.LogInformation(new string('2', LongLineCharacters));
logger.LogInformation(new string('3', LongLineCharacters));
logger.LogInformation("Test1");
logger.LogInformation("Test2");
var context = TestServerCallContext.Create();
var writer = new TestServerStreamWriter<WatchResourceConsoleLogsUpdate>(context);
// Act
var task = dashboardService.WatchResourceConsoleLogs(
new WatchResourceConsoleLogsRequest { ResourceName = "test-resource", SuppressFollow = true },
writer,
context);
// Assert
var update1 = await writer.ReadNextAsync().DefaultTimeout();
Assert.Collection(update1.LogLines,
l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length),
l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length));
var update2 = await writer.ReadNextAsync().DefaultTimeout();
Assert.Collection(update2.LogLines,
l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length),
l => Assert.Equal("Test1", l.Text.Split(' ')[1]),
l => Assert.Equal("Test2", l.Text.Split(' ')[1]));
await getConsoleLogsChannel.Writer.WriteAsync([LogEntry.Create(null, "Test3", isErrorMessage: false)]);
var update3 = await writer.ReadNextAsync().DefaultTimeout();
Assert.Collection(update3.LogLines,
l => Assert.Equal("Test3", l.Text));
Assert.False(task.IsCompleted, "Waiting for channel to complete.");
getConsoleLogsChannel.Writer.TryComplete();
await task.DefaultTimeout();
}
[Fact]
public async Task WatchResourceConsoleLogs_LargePendingData_BatchResults()
{
// Arrange
const int LongLineCharacters = DashboardServiceImpl.LogMaxBatchCharacters / 3;
var resourceLoggerService = new ResourceLoggerService();
var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
var dashboardService = CreateDashboardService(dashboardServiceData);
var logger = resourceLoggerService.GetLogger("test-resource");
// Exceed limit line
logger.LogInformation(new string('1', DashboardServiceImpl.LogMaxBatchCharacters));
// Three long lines
logger.LogInformation(new string('2', LongLineCharacters));
logger.LogInformation(new string('3', LongLineCharacters));
logger.LogInformation(new string('4', LongLineCharacters));
var context = TestServerCallContext.Create();
var writer = new TestServerStreamWriter<WatchResourceConsoleLogsUpdate>(context);
// Act
var task = dashboardService.WatchResourceConsoleLogs(
new WatchResourceConsoleLogsRequest { ResourceName = "test-resource" },
writer,
context);
// Assert
var exceedLimitUpdate = await writer.ReadNextAsync().DefaultTimeout();
Assert.Collection(exceedLimitUpdate.LogLines,
l => Assert.Equal(DashboardServiceImpl.LogMaxBatchCharacters, l.Text.Length));
var longLinesUpdate1 = await writer.ReadNextAsync().DefaultTimeout();
Assert.Collection(longLinesUpdate1.LogLines,
l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length),
l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length));
var longLinesUpdate2 = await writer.ReadNextAsync().DefaultTimeout();
Assert.Collection(longLinesUpdate2.LogLines,
l => Assert.Equal(LongLineCharacters, l.Text.Split(' ')[1].Length));
resourceLoggerService.Complete("test-resource");
await task.DefaultTimeout();
}
[Fact]
public async Task WatchResources_ResourceHasCommands_CommandsSentWithResponse()
{
// Arrange
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddXunit(testOutputHelper);
});
var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
var resourceLoggerService = new ResourceLoggerService();
var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
var testResource = new TestResource("test-resource");
using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
var builder = applicationBuilder.AddResource(testResource);
#pragma warning disable CS0618 // Parameter is obsolete but this verifies dashboard wire compatibility.
builder.WithCommand(
name: "TestName",
displayName: "Display name!",
executeCommand: c => Task.FromResult(CommandResults.Success()),
commandOptions: new()
{
UpdateState = c => Hosting.ApplicationModel.ResourceCommandState.Enabled,
Description = "Display description!",
Parameter = new[] { "One", "Two" },
Arguments =
[
new InteractionInput
{
Name = "selector",
Label = "Selector",
Description = "CSS selector to click.",
InputType = InputType.Text,
Required = true,
Placeholder = "#submit"
}
],
ConfirmationMessage = "Confirmation message!",
IconName = "Icon name!",
IconVariant = Hosting.ApplicationModel.IconVariant.Filled,
IsHighlighted = true
});
#pragma warning restore CS0618
builder.WithCommand(
name: "HeadlessName",
displayName: "Headless display name",
executeCommand: c => Task.FromResult(CommandResults.Success()),
commandOptions: new()
{
UpdateState = c => Hosting.ApplicationModel.ResourceCommandState.Enabled,
Visibility = ResourceCommandVisibility.Api
});
builder.WithCommand(
name: "UnknownStateName",
displayName: "Unknown state display name",
executeCommand: c => Task.FromResult(CommandResults.Success()),
commandOptions: new()
{
UpdateState = c => (Hosting.ApplicationModel.ResourceCommandState)999
});
logger.LogInformation("Publishing resource.");
await resourceNotificationService.PublishUpdateAsync(testResource, s =>
{
return s with { State = new ResourceStateSnapshot("Starting", null) };
}).DefaultTimeout();
logger.LogInformation("Waiting for the resource with a command. Required so added resource is always in the service's initial data collection");
await dashboardServiceData.WaitForResourceAsync(testResource.Name, r =>
{
return r.Commands.Length == 3;
}).DefaultTimeout();
var cts = new CancellationTokenSource();
var context = TestServerCallContext.Create(cancellationToken: cts.Token);
var writer = new TestServerStreamWriter<WatchResourcesUpdate>(context);
// Act
logger.LogInformation("Calling WatchResources.");
var task = dashboardService.WatchResources(
new WatchResourcesRequest(),
writer,
context);
logger.LogInformation("Reading result from writer.");
var readUpdateTask = writer.ReadNextAsync().DefaultTimeout();
var completedTask = await Task.WhenAny(task, readUpdateTask).DefaultTimeout();
await completedTask.DefaultTimeout();
Assert.Same(readUpdateTask, completedTask);
var update = await readUpdateTask;
Assert.False(task.IsCompleted, "WatchResources should remain active until cancellation.");
logger.LogInformation($"Initial data count: {update.InitialData.Resources.Count}");
var resourceData = Assert.Single(update.InitialData.Resources);
logger.LogInformation($"Commands count: {resourceData.Commands.Count}");
Assert.Collection(resourceData.Commands,
commandData =>
{
Assert.Equal("TestName", commandData.Name);
Assert.Equal("Display name!", commandData.DisplayName);
Assert.Equal("Display description!", commandData.DisplayDescription);
#pragma warning disable CS0612 // Parameter is obsolete but still verified for compatibility.
Assert.Equal(Value.ForList(Value.ForString("One"), Value.ForString("Two")), commandData.Parameter);
#pragma warning restore CS0612
var argumentInput = Assert.Single(commandData.ArgumentInputs);
Assert.Equal("selector", argumentInput.Name);
Assert.Equal("Selector", argumentInput.Label);
Assert.Equal("CSS selector to click.", argumentInput.Description);
Assert.Equal(DashboardService.Proto.V1.InputType.Text, argumentInput.InputType);
Assert.True(argumentInput.Required);
Assert.Equal("#submit", argumentInput.Placeholder);
Assert.Equal("Confirmation message!", commandData.ConfirmationMessage);
Assert.Equal("Icon name!", commandData.IconName);
Assert.Equal(DashboardService.Proto.V1.IconVariant.Filled, commandData.IconVariant);
Assert.True(commandData.IsHighlighted);
Assert.Equal(DashboardService.Proto.V1.ResourceCommandState.Enabled, commandData.State);
},
commandData =>
{
Assert.Equal("UnknownStateName", commandData.Name);
Assert.Equal("Unknown state display name", commandData.DisplayName);
Assert.Equal(DashboardService.Proto.V1.ResourceCommandState.Hidden, commandData.State);
});
await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
}
[Fact]
public async Task ExecuteResourceCommand_WithArguments_PassesArgumentsToCommand()
{
var resourceLoggerService = new ResourceLoggerService();
var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
using var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
var dashboardService = CreateDashboardService(dashboardServiceData);
InteractionInputCollection? capturedArguments = null;
var testResource = new TestResource("test-resource");
using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
var builder = applicationBuilder.AddResource(testResource);
builder.WithCommand(
name: "click",
displayName: "Click",
executeCommand: c =>
{
capturedArguments = c.Arguments;
return Task.FromResult(CommandResults.Success());
},
commandOptions: new()
{
Visibility = ResourceCommandVisibility.Api,
Arguments =
[
new InteractionInput
{
Name = "selector",
InputType = InputType.Text
},
new InteractionInput
{
Name = "clickCount",
InputType = InputType.Number
}
]
});
await resourceNotificationService.PublishUpdateAsync(testResource, s =>
{
return s with { State = new ResourceStateSnapshot("Running", null) };
}).DefaultTimeout();
var context = TestServerCallContext.Create();
var response = await dashboardService.ExecuteResourceCommand(
new ResourceCommandRequest
{
ResourceName = testResource.Name,
CommandName = "click",
Arguments =
{
["selector"] = Value.ForString("#submit"),
["clickCount"] = Value.ForNumber(2)
}
},
context);
Assert.Equal(ResourceCommandResponseKind.Succeeded, response.Kind);
Assert.NotNull(capturedArguments);
Assert.Equal("#submit", capturedArguments.GetString("selector"));
Assert.Equal(2, capturedArguments.GetInt32("clickCount"));
}
[Fact]
public async Task ExecuteResourceCommand_WithUnknownArgument_ReturnsFailure()
{
var resourceLoggerService = new ResourceLoggerService();
var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
using var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
var dashboardService = CreateDashboardService(dashboardServiceData);
var executed = false;
var testResource = new TestResource("test-resource");
using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
var builder = applicationBuilder.AddResource(testResource);
builder.WithCommand(
name: "click",
displayName: "Click",
executeCommand: c =>
{
executed = true;
return Task.FromResult(CommandResults.Success());
},
commandOptions: new()
{
Visibility = ResourceCommandVisibility.Api,
Arguments =
[
new InteractionInput
{
Name = "selector",
InputType = InputType.Text,
Required = true
}
]
});
await resourceNotificationService.PublishUpdateAsync(testResource, s =>
{
return s with { State = new ResourceStateSnapshot("Running", null) };
}).DefaultTimeout();
var context = TestServerCallContext.Create();
var response = await dashboardService.ExecuteResourceCommand(
new ResourceCommandRequest
{
ResourceName = testResource.Name,
CommandName = "click",
Arguments =
{
["selecter"] = Value.ForString("#submit")
}
},
context);
Assert.Equal(ResourceCommandResponseKind.Failed, response.Kind);
Assert.False(executed);
Assert.Equal("Unknown argument 'selecter' for command 'click'.", response.Message);
}
[Fact]
public async Task ExecuteResourceCommand_WithInvalidArguments_ReturnsValidationErrors()
{
var resourceLoggerService = new ResourceLoggerService();
var resourceNotificationService = CreateResourceNotificationService(resourceLoggerService);
using var dashboardServiceData = CreateDashboardServiceData(resourceLoggerService: resourceLoggerService, resourceNotificationService: resourceNotificationService);
var dashboardService = CreateDashboardService(dashboardServiceData);
var executed = false;
var testResource = new TestResource("test-resource");
using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
var builder = applicationBuilder.AddResource(testResource);
builder.WithCommand(
name: "validate",
displayName: "Validate",
executeCommand: c =>
{
executed = true;
return Task.FromResult(CommandResults.Success());
},
commandOptions: new()
{
Visibility = ResourceCommandVisibility.Api,
Arguments =
[
new InteractionInput
{
Name = "target",
InputType = InputType.Text
}
],
ValidateArguments = context =>
{
var target = context.Inputs.Single(argument => argument.Name == "target");
context.AddValidationError(target, "Target must not be prod.");
return Task.CompletedTask;
}
});
await resourceNotificationService.PublishUpdateAsync(testResource, s =>
{
return s with { State = new ResourceStateSnapshot("Running", null) };
}).DefaultTimeout();
var context = TestServerCallContext.Create();
var response = await dashboardService.ExecuteResourceCommand(
new ResourceCommandRequest
{
ResourceName = testResource.Name,
CommandName = "validate",
Arguments =
{
["target"] = Value.ForString("prod")
}
},
context);
Assert.Equal(ResourceCommandResponseKind.InvalidArguments, response.Kind);
Assert.Equal("Command argument validation failed.", response.Message);
Assert.False(executed);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
[InlineData(null)]
public async Task WatchInteractions_PromptMessageBoxAsync_CompleteOnResponse(bool? result)
{
// Arrange
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddXunit(testOutputHelper);
});
var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
var interactionService = new InteractionService(
loggerFactory.CreateLogger<InteractionService>(),
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
new TestInteractionFileUploadStore());
using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
var cts = new CancellationTokenSource();
var context = TestServerCallContext.Create(cancellationToken: cts.Token);
var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
// Act
logger.LogInformation("Calling WatchInteractions.");
var task = dashboardService.WatchInteractions(
reader,
writer,
context);
var resultTask = interactionService.PromptMessageBoxAsync(
title: "Title!",
message: "Message!");
// Assert
logger.LogInformation("Reading result from writer.");
var update = await writer.ReadNextAsync().DefaultTimeout();
Assert.NotEqual(0, update.InteractionId);
Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.MessageBox, update.KindCase);
Assert.False(resultTask.IsCompleted);
logger.LogInformation("Send result to reader.");
if (result != null)
{
update.MessageBox.Result = result.Value;
reader.AddMessage(new WatchInteractionsRequestUpdate
{
InteractionId = update.InteractionId,
MessageBox = update.MessageBox
});
Assert.Equal(result, (await resultTask.DefaultTimeout()).Data);
}
else
{
reader.AddMessage(new WatchInteractionsRequestUpdate
{
InteractionId = update.InteractionId,
Complete = new InteractionComplete()
});
Assert.True((await resultTask.DefaultTimeout()).Canceled);
}
await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
}
[Fact]
public async Task WatchInteractions_NoExplicitLabel_LabelIsName()
{
// Arrange
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddXunit(testOutputHelper);
});
var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
var interactionService = new InteractionService(
loggerFactory.CreateLogger<InteractionService>(),
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
new TestInteractionFileUploadStore());
using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
var cts = new CancellationTokenSource();
var context = TestServerCallContext.Create(cancellationToken: cts.Token);
var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
// Act
logger.LogInformation("Calling WatchInteractions.");
var task = dashboardService.WatchInteractions(
reader,
writer,
context);
var resultTask = interactionService.PromptInputAsync(
title: "Title!",
message: "Message!",
new InteractionInput { Name = "Input", InputType = InputType.Text });
// Assert
logger.LogInformation("Reading result from writer.");
var update = await writer.ReadNextAsync().DefaultTimeout();
Assert.NotEqual(0, update.InteractionId);
Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog, update.KindCase);
Assert.Equal("Input", Assert.Single(update.InputsDialog.InputItems).Label);
await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
}
[Fact]
public async Task WatchInteractions_PromptInputAsync_CompleteOnCancelResponse()
{
// Arrange
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddXunit(testOutputHelper);
});
var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
var interactionService = new InteractionService(
loggerFactory.CreateLogger<InteractionService>(),
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
new TestInteractionFileUploadStore());
using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
var cts = new CancellationTokenSource();
var context = TestServerCallContext.Create(cancellationToken: cts.Token);
var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
// Act
logger.LogInformation("Calling WatchInteractions.");
var task = dashboardService.WatchInteractions(
reader,
writer,
context);
var resultTask = interactionService.PromptInputAsync(
title: "Title!",
message: "Message!",
new InteractionInput { Name = "Input", InputType = InputType.Text, Label = "Input" });
// Assert
logger.LogInformation("Reading result from writer.");
var update = await writer.ReadNextAsync().DefaultTimeout();
Assert.NotEqual(0, update.InteractionId);
Assert.Equal(WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog, update.KindCase);
Assert.False(resultTask.IsCompleted);
logger.LogInformation("Send result to reader.");
reader.AddMessage(new WatchInteractionsRequestUpdate
{
InteractionId = update.InteractionId,
Complete = new InteractionComplete()
});
var result = await resultTask.DefaultTimeout();
Assert.True(result.Canceled);
Assert.Null(result.Data);
await CancelTokenAndAwaitTask(cts, task).DefaultTimeout();
}
[Fact]
public async Task WatchInteractions_ReaderError_CompleteWithError()
{
// Arrange
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddXunit(testOutputHelper);
});
var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
var interactionService = new InteractionService(
loggerFactory.CreateLogger<InteractionService>(),
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
new TestInteractionFileUploadStore());
using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
var cts = new CancellationTokenSource();
var context = TestServerCallContext.Create(cancellationToken: cts.Token);
var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
// Act
logger.LogInformation("Calling WatchInteractions.");
var task = dashboardService.WatchInteractions(
reader,
writer,
context);
reader.Complete(new InvalidOperationException("Error!"));
// Assert
await Assert.ThrowsAnyAsync<Exception>(() => task).DefaultTimeout();
}
[Fact]
public async Task WatchInteractions_WriterError_CompleteWithError()
{
// Arrange
var loggerFactory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(LogLevel.Trace);
builder.AddXunit(testOutputHelper);
});
var logger = loggerFactory.CreateLogger<DashboardServiceTests>();
var interactionService = new InteractionService(
loggerFactory.CreateLogger<InteractionService>(),
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
new TestInteractionFileUploadStore());
using var dashboardServiceData = CreateDashboardServiceData(loggerFactory: loggerFactory, interactionService: interactionService);
var dashboardService = CreateDashboardService(dashboardServiceData, logger: loggerFactory.CreateLogger<DashboardServiceImpl>());
var cts = new CancellationTokenSource();
var context = TestServerCallContext.Create(cancellationToken: cts.Token);
var writer = new TestServerStreamWriter<WatchInteractionsResponseUpdate>(context);
var reader = new TestAsyncStreamReader<WatchInteractionsRequestUpdate>(context);
// Act
logger.LogInformation("Calling WatchInteractions.");
var task = dashboardService.WatchInteractions(
reader,
writer,
context);
writer.Complete(new InvalidOperationException("Error!"));
_ = interactionService.PromptMessageBoxAsync(
title: "Title!",
message: "Message!");
// Assert
await Assert.ThrowsAnyAsync<Exception>(() => task).DefaultTimeout();
}
[Fact]
public void WithCommandOverloadNotAmbiguous()
{
var testResource = new TestResource("test-resource");
using var applicationBuilder = TestDistributedApplicationBuilder.Create(testOutputHelper: testOutputHelper);
var builder = applicationBuilder.AddResource(testResource);
builder.WithCommand(
name: "TestName",
displayName: "Display name!",
executeCommand: c => Task.FromResult(CommandResults.Success()));
// This test simply needs to compile.
Assert.True(true);
}
[Fact]
public async Task GetApplicationInformation_ReadsFromConfiguration()
{
// Arrange
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["AppHost:DashboardApplicationName"] = "MyCustomAppName"
});
var configuration = configBuilder.Build();
var dashboardServiceData = CreateDashboardServiceData();
var hostEnvironment = new TestHostEnvironment
{
ApplicationName = "DefaultAppName"
};
var dashboardService = CreateDashboardService(dashboardServiceData, hostEnvironment: hostEnvironment, configuration: configuration);
var context = TestServerCallContext.Create();
// Act
var response = await dashboardService.GetApplicationInformation(
new ApplicationInformationRequest(),
context);
// Assert
Assert.Equal("MyCustomAppName", response.ApplicationName);
}
[Fact]
public async Task GetApplicationInformation_FallsBackToEnvironmentApplicationName()
{
// Arrange
var configuration = new ConfigurationBuilder().Build(); // Empty configuration
var dashboardServiceData = CreateDashboardServiceData();
var hostEnvironment = new TestHostEnvironment
{
ApplicationName = "FallbackAppName"
};
var dashboardService = CreateDashboardService(dashboardServiceData, hostEnvironment: hostEnvironment, configuration: configuration);
var context = TestServerCallContext.Create();
// Act
var response = await dashboardService.GetApplicationInformation(
new ApplicationInformationRequest(),
context);
// Assert
Assert.Equal("FallbackAppName", response.ApplicationName);
}
[Fact]
public async Task GetApplicationInformation_StripsAppHostSuffix()
{
// Arrange
var configBuilder = new ConfigurationBuilder();
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
{
["AppHost:DashboardApplicationName"] = "MyApp.AppHost"
});
var configuration = configBuilder.Build();
var dashboardServiceData = CreateDashboardServiceData();
var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration);
var context = TestServerCallContext.Create();
// Act
var response = await dashboardService.GetApplicationInformation(
new ApplicationInformationRequest(),
context);
// Assert
// The ComputeApplicationName method should strip the .AppHost suffix
Assert.Equal("MyApp", response.ApplicationName);
}
[Theory]
[InlineData(InputType.File, 0, -1, 0)] // File, no input max, no server limit → 0
[InlineData(InputType.File, 0, 50, 50)] // File, no input max, 50 MB server limit → 50 MB
[InlineData(InputType.File, 5, 100, 5)] // File, 5 MB input max below 100 MB server → uses input
[InlineData(InputType.File, 200, 100, 100)] // File, 200 MB input max exceeds 100 MB server → capped
[InlineData(InputType.File, 50, -1, 50)] // File, 50 MB input max, no server limit → uses input
[InlineData(InputType.Text, 0, 100, 0)] // Non-file, server limit not applied → 0
public void CreateInteractionInputDto_MaxFileSize_RespectsLimits(
InputType inputType, int inputMaxFileSizeMB, int serverLimitMB, int expectedMB)
{
var input = inputMaxFileSizeMB > 0
? new InteractionInput { Name = "TestInput", Label = "Test Input", InputType = inputType, MaxFileSize = inputMaxFileSizeMB * 1024 * 1024 }
: new InteractionInput { Name = "TestInput", Label = "Test Input", InputType = inputType };
long? serverLimit = serverLimitMB >= 0 ? serverLimitMB * 1024L * 1024 : null;
var dto = DashboardServiceImpl.CreateInteractionInputDto(input, maxFileUploadSize: serverLimit);
Assert.Equal(expectedMB * 1024L * 1024, dto.MaxFileSize);
}
[Fact]
public async Task UploadFile_WithinSizeLimit_Succeeds()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
var data = new byte[1024]; // 1 KB
Array.Fill(data, (byte)'A');
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = "test.txt", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "File" });
requestStream.Complete();
var response = await dashboardService.UploadFile(requestStream, context);
Assert.NotNull(response.FileId);
Assert.NotEmpty(response.FileId);
var filePath = Assert.IsType<string>(fileUploadStore.GetFilePath(response.FileId, 1, "File"));
fileUploadStore.CancelInteraction(1);
Assert.Null(fileUploadStore.GetFilePath(response.FileId, 1, "File"));
Assert.False(File.Exists(filePath));
}
[Theory]
[InlineData("../../../etc/passwd", "passwd")]
[InlineData("..\\..\\windows\\system32\\evil.exe", "evil.exe")]
[InlineData("bad*.txt", "bad*.txt")]
[InlineData("bad:name.txt", "bad:name.txt")]
[InlineData("CON.txt", "CON.txt")]
[InlineData("bad\0name.txt", "bad\0name.txt")]
public async Task UploadFile_MaliciousFileName_UsesRandomDiskName(string fileName, string expectedFileName)
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", 1)]);
var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = fileName, InteractionId = 1, InputName = "File" });
requestStream.Complete();
var response = await dashboardService.UploadFile(requestStream, context);
var filePath = Assert.IsType<string>(fileUploadStore.GetFilePath(response.FileId, 1, "File"));
Assert.True(File.Exists(filePath));
Assert.NotEqual(expectedFileName, Path.GetFileName(filePath));
Assert.Equal(expectedFileName, Assert.Single(fileUploadStore.GetCompletedFiles(1, "File")).Name);
}
[Fact]
public async Task UploadFile_ExceedsInputFileCountLimit_ThrowsFailedPrecondition()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", 1)]);
var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
var firstContext = TestServerCallContext.Create();
var firstRequestStream = new TestAsyncStreamReader<UploadFileChunk>(firstContext);
firstRequestStream.AddMessage(new UploadFileChunk { FileName = "first.txt", InteractionId = 1, InputName = "File" });
firstRequestStream.Complete();
await dashboardService.UploadFile(firstRequestStream, firstContext);
var secondContext = TestServerCallContext.Create();
var secondRequestStream = new TestAsyncStreamReader<UploadFileChunk>(secondContext);
secondRequestStream.AddMessage(new UploadFileChunk { FileName = "second.txt", InteractionId = 1, InputName = "File" });
secondRequestStream.Complete();
var exception = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(secondRequestStream, secondContext));
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal("File input 'File' accepts at most 1 file.", exception.Status.Detail);
}
[Fact]
public async Task UploadFile_ExceedsConfiguredSizeLimit_ThrowsResourceExhausted()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.MaxFileUploadSize] = "1024" // 1 KB limit
})
.Build();
var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration, fileUploadStore: fileUploadStore);
var data = new byte[2048]; // 2 KB - exceeds the 1 KB limit
Array.Fill(data, (byte)'A');
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = "large.txt", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "File" });
requestStream.Complete();
var ex = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
Assert.Equal(StatusCode.ResourceExhausted, ex.StatusCode);
fileUploadStore.CancelInteraction(1);
fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
var (_, replacementPath) = fileUploadStore.CreateEntry("replacement.txt", 1, "File");
Assert.True(File.Exists(replacementPath));
}
[Fact]
public async Task UploadFile_ExceedsLimitAcrossMultipleChunks_ThrowsResourceExhausted()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.MaxFileUploadSize] = "1500" // 1500 bytes limit
})
.Build();
var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration, fileUploadStore: fileUploadStore);
var chunk1 = new byte[1024]; // 1 KB
var chunk2 = new byte[1024]; // 1 KB - total 2 KB exceeds 1500
Array.Fill(chunk1, (byte)'A');
Array.Fill(chunk2, (byte)'B');
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = "large.txt", Data = ByteString.CopyFrom(chunk1), InteractionId = 1, InputName = "File" });
requestStream.AddMessage(new UploadFileChunk { Data = ByteString.CopyFrom(chunk2) });
requestStream.Complete();
var ex = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
Assert.Equal(StatusCode.ResourceExhausted, ex.StatusCode);
}
[Fact]
public async Task UploadFile_ConfiguredSizeLimit_AllowsWithinLimitUploads()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.MaxFileUploadSize] = "10485760" // 10 MB
})
.Build();
var dashboardService = CreateDashboardService(dashboardServiceData, configuration: configuration, fileUploadStore: fileUploadStore);
var data = new byte[1024 * 1024]; // 1 MB - within 10 MB limit
Array.Fill(data, (byte)'A');
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = "medium.bin", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "File" });
requestStream.Complete();
var response = await dashboardService.UploadFile(requestStream, context);
Assert.NotNull(response.FileId);
Assert.NotEmpty(response.FileId);
}
[Fact]
public async Task UploadFile_EmptyStream_ThrowsInvalidArgument()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.Complete(); // empty stream
var ex = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
}
[Theory]
[InlineData("", 1, "File", "First chunk must include a file name.")]
[InlineData("file.txt", 0, "File", "First chunk must include an interaction ID.")]
[InlineData("file.txt", 1, "", "First chunk must include an input name.")]
public async Task UploadFile_FirstChunkMissingRequiredMetadata_ThrowsInvalidArgument(
string fileName,
int interactionId,
string inputName,
string expectedMessage)
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", InteractionHelpers.MaxFileCount)]);
var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = fileName, InteractionId = interactionId, InputName = inputName });
requestStream.Complete();
var exception = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Equal(expectedMessage, exception.Status.Detail);
}
[Fact]
public async Task UploadFile_UnknownInteraction_RejectsUpload()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = "file.txt", InteractionId = 1, InputName = "File" });
requestStream.Complete();
var exception = await Assert.ThrowsAsync<RpcException>(() => dashboardService.UploadFile(requestStream, context));
Assert.Equal(StatusCode.FailedPrecondition, exception.StatusCode);
Assert.Equal("Interaction '1' is not accepting file uploads.", exception.Status.Detail);
}
[Fact]
public async Task SendInteractionRequestAsync_ClientFileTypeForTextInput_DoesNotAttachFile()
{
var fileUploadStore = new TestInteractionFileUploadStore();
var interactionService = new InteractionService(
NullLogger<InteractionService>.Instance,
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
fileUploadStore);
using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore);
var fileInput = new InteractionInput { Name = "File", InputType = InputType.File };
var textInput = new InteractionInput { Name = "Text", InputType = InputType.Text };
var resultTask = interactionService.PromptInputsAsync("Inputs", "Enter values", [fileInput, textInput]);
var interaction = Assert.Single(interactionService.GetCurrentInteractions());
var (fileId, _) = fileUploadStore.CreateEntry("spoofed.txt", interaction.InteractionId, textInput.Name);
var value = $"[{{\"Id\":\"{fileId}\",\"Name\":\"spoofed.txt\"}}]";
var request = new WatchInteractionsRequestUpdate
{
InteractionId = interaction.InteractionId,
InputsDialog = new InteractionInputsDialog()
};
request.InputsDialog.InputItems.Add(new Aspire.DashboardService.Proto.V1.InteractionInput
{
Name = textInput.Name,
InputType = Aspire.DashboardService.Proto.V1.InputType.File,
Value = value
});
await dashboardServiceData.SendInteractionRequestAsync(request, CancellationToken.None);
var result = await resultTask;
var resultInputs = Assert.IsType<InteractionInputCollection>(result.Data);
Assert.Empty(resultInputs[textInput.Name].GetFiles());
Assert.Equal(value, resultInputs[textInput.Name].Value);
}
[Fact]
public async Task SendInteractionRequestAsync_UsesAuthoritativeFilesAndDisposeDeletesUploads()
{
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
var interactionService = new InteractionService(
NullLogger<InteractionService>.Instance,
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
fileUploadStore);
using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore);
var input = new InteractionInput { Name = "File", InputType = InputType.File, Required = true, AllowMultipleFiles = true };
var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input);
var interaction = Assert.Single(interactionService.GetCurrentInteractions());
var (fileId, filePath) = fileUploadStore.CreateEntry("document.txt", interaction.InteractionId, input.Name);
var (secondFileId, secondFilePath) = fileUploadStore.CreateEntry("second.txt", interaction.InteractionId, input.Name);
await File.WriteAllTextAsync(filePath, "content");
await File.WriteAllTextAsync(secondFilePath, "second content");
fileUploadStore.CompleteUpload(interaction.InteractionId, fileId);
fileUploadStore.CompleteUpload(interaction.InteractionId, secondFileId);
var request = new WatchInteractionsRequestUpdate
{
InteractionId = interaction.InteractionId,
InputsDialog = new InteractionInputsDialog()
};
request.InputsDialog.InputItems.Add(new Aspire.DashboardService.Proto.V1.InteractionInput
{
Name = input.Name,
InputType = Aspire.DashboardService.Proto.V1.InputType.File,
Value = $"[{{\"Id\":\"{fileId}\",\"Name\":\"spoofed.txt\"}},{{\"Id\":\"{secondFileId}\",\"Name\":\"also-spoofed.txt\"}}]"
});
await dashboardServiceData.SendInteractionRequestAsync(request, CancellationToken.None);
var result = await resultTask;
var resultInput = Assert.IsType<InteractionInput>(result.Data);
var files = resultInput.GetFiles();
Assert.Equal(2, files.Count);
var file = files[0];
var secondFile = files[1];
Assert.Equal(filePath, file.FilePath);
Assert.Equal("document.txt", file.Name);
Assert.Equal(secondFilePath, secondFile.FilePath);
Assert.Equal("second.txt", secondFile.Name);
Assert.True(File.Exists(filePath));
Assert.True(File.Exists(secondFilePath));
Assert.Equal("content", Encoding.UTF8.GetString(await file.ReadAllBytesAsync()));
await using var stream = file.OpenRead();
using var reader = new StreamReader(stream);
Assert.Equal("content", await reader.ReadToEndAsync());
var readAllBytesTask = file.ReadAllBytesAsync();
files.Dispose();
files.Dispose();
Assert.False(File.Exists(filePath));
Assert.False(File.Exists(secondFilePath));
Assert.Equal("content", Encoding.UTF8.GetString(await readAllBytesTask));
Assert.Null(fileUploadStore.GetFilePath(fileId, interaction.InteractionId, input.Name));
Assert.Null(fileUploadStore.GetFilePath(secondFileId, interaction.InteractionId, input.Name));
Assert.Throws<ObjectDisposedException>(file.OpenRead);
await Assert.ThrowsAsync<ObjectDisposedException>(ReadAllBytesAfterDisposeAsync);
Task ReadAllBytesAfterDisposeAsync() => file.ReadAllBytesAsync();
}
[Fact]
public async Task SendInteractionRequestAsync_MismatchedFiles_Throws()
{
var fileUploadStore = new TestInteractionFileUploadStore();
var interactionService = new InteractionService(
NullLogger<InteractionService>.Instance,
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
fileUploadStore);
using var dashboardServiceData = CreateDashboardServiceData(interactionService: interactionService, fileUploadStore: fileUploadStore);
var input = new InteractionInput { Name = "File", InputType = InputType.File };
var resultTask = interactionService.PromptInputAsync("Upload", "Select a file", input);
var interaction = Assert.Single(interactionService.GetCurrentInteractions());
var (fileId, _) = fileUploadStore.CreateEntry("document.txt", interaction.InteractionId, input.Name);
fileUploadStore.CompleteUpload(interaction.InteractionId, fileId);
var request = new WatchInteractionsRequestUpdate
{
InteractionId = interaction.InteractionId,
InputsDialog = new InteractionInputsDialog()
};
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
dashboardServiceData.SendInteractionRequestAsync(request, CancellationToken.None));
Assert.Equal("Submitted files for input 'File' do not match the completed uploads.", exception.Message);
await dashboardServiceData.SendInteractionRequestAsync(
new WatchInteractionsRequestUpdate
{
InteractionId = interaction.InteractionId,
Complete = new InteractionComplete()
},
CancellationToken.None);
Assert.True((await resultTask).Canceled);
}
[Fact]
public async Task UploadFile_ThenResolveFiles_ResolvesCorrectly()
{
var dashboardServiceData = CreateDashboardServiceData();
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("CertInput", InteractionHelpers.MaxFileCount)]);
var dashboardService = CreateDashboardService(dashboardServiceData, fileUploadStore: fileUploadStore);
// Upload a file
var data = Encoding.UTF8.GetBytes("certificate-content");
var context = TestServerCallContext.Create();
var requestStream = new TestAsyncStreamReader<UploadFileChunk>(context);
requestStream.AddMessage(new UploadFileChunk { FileName = "cert.pem", Data = ByteString.CopyFrom(data), InteractionId = 1, InputName = "CertInput" });
requestStream.Complete();
var uploadResponse = await dashboardService.UploadFile(requestStream, context);
var json = $"[{{\"Id\":\"{uploadResponse.FileId}\"}}]";
var resolvedFiles = fileUploadStore.GetCompletedFiles(1, "CertInput");
InteractionFileUploadStore.ValidateFileReferences(json, "CertInput", resolvedFiles);
Assert.NotNull(resolvedFiles);
var file = Assert.Single(resolvedFiles);
Assert.Equal(uploadResponse.FileId, file.Id);
Assert.Equal("cert.pem", file.Name);
Assert.True(File.Exists(file.FilePath));
// Verify the file content was written correctly
var content = await File.ReadAllBytesAsync(file.FilePath);
Assert.Equal(data, content);
}
[Fact]
public void ResolveFiles_UnknownInteraction_ReturnsNull()
{
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
var result = fileUploadStore.GetCompletedFiles(1, "TestInput");
InteractionFileUploadStore.ValidateFileReferences(jsonValue: null, "TestInput", result);
Assert.Empty(result);
}
[Fact]
public void ResolveFiles_UnknownInput_ReturnsNull()
{
using var fileSystemService = new TestFileSystemService();
using var fileUploadStore = CreateFileUploadStore(fileSystemService);
fileUploadStore.StartInteraction(1, [("File", 1)]);
var (fileId, _) = fileUploadStore.CreateEntry("file.txt", 1, "File");
fileUploadStore.CompleteUpload(1, fileId);
var result = fileUploadStore.GetCompletedFiles(1, "OtherFile");
InteractionFileUploadStore.ValidateFileReferences(jsonValue: null, "OtherFile", result);
Assert.Empty(result);
}
private static DashboardServiceImpl CreateDashboardService(
DashboardServiceData dashboardServiceData,
IHostEnvironment? hostEnvironment = null,
IConfiguration? configuration = null,
ILogger<DashboardServiceImpl>? logger = null,
IInteractionFileUploadStore? fileUploadStore = null)
{
return new DashboardServiceImpl(
dashboardServiceData,
hostEnvironment ?? new TestHostEnvironment(),
new TestHostApplicationLifetime(),
configuration ?? new ConfigurationBuilder().Build(),
logger ?? NullLogger<DashboardServiceImpl>.Instance,
fileUploadStore ?? new TestInteractionFileUploadStore());
}
private static DashboardServiceData CreateDashboardServiceData(
ResourceLoggerService? resourceLoggerService = null,
ResourceNotificationService? resourceNotificationService = null,
ILoggerFactory? loggerFactory = null,
InteractionService? interactionService = null,
IInteractionFileUploadStore? fileUploadStore = null)
{
resourceLoggerService ??= new ResourceLoggerService();
loggerFactory ??= NullLoggerFactory.Instance;
resourceNotificationService ??= CreateResourceNotificationService(resourceLoggerService);
fileUploadStore ??= new TestInteractionFileUploadStore();
interactionService ??= new InteractionService(
NullLogger<InteractionService>.Instance,
new DistributedApplicationOptions(),
new ServiceCollection().BuildServiceProvider(),
new ConfigurationBuilder().Build(),
fileUploadStore);
return new DashboardServiceData(
resourceNotificationService,
resourceLoggerService,
loggerFactory.CreateLogger<DashboardServiceData>(),
new ResourceCommandService(resourceNotificationService, resourceLoggerService, new ServiceCollection().BuildServiceProvider()),
interactionService,
fileUploadStore);
}
private static ResourceNotificationService CreateResourceNotificationService(ResourceLoggerService resourceLoggerService)
{
return new ResourceNotificationService(NullLogger<ResourceNotificationService>.Instance, new TestHostApplicationLifetime(), new ServiceCollection().BuildServiceProvider(), resourceLoggerService);
}
private sealed class TestHostEnvironment : IHostEnvironment
{
public string ApplicationName { get; set; } = default!;
public IFileProvider ContentRootFileProvider { get; set; } = default!;
public string ContentRootPath { get; set; } = default!;
public string EnvironmentName { get; set; } = default!;
}
private sealed class TestResource(string name) : Resource(name)
{
}
private static async Task CancelTokenAndAwaitTask(CancellationTokenSource cts, Task task)
{
await cts.CancelAsync();
try
{
await task;
}
catch (OperationCanceledException)
{
// Ok if this error is thrown.
}
}
private static InteractionFileUploadStore CreateFileUploadStore(IFileSystemService fileSystemService) =>
new(fileSystemService, NullLogger<InteractionFileUploadStore>.Instance);
}