@foreach (var (wid, dname) in _parsedPresetMods)
{
+ bool alreadyClient = Instance.Mods.Any(m => m.IsClientSide && m.SteamMod?.WorkshopId == wid);
bool alreadyIn = Instance.Mods.Any(m => m.SteamMod?.WorkshopId == wid);
-
+
@@ -155,7 +169,7 @@
@if (alreadyIn)
{
in instance
+ Variant="Variant.Text">loaded
}
}
@@ -175,9 +189,9 @@
Cancel
- Add @_presetSelectedIds.Count mod(s)
+ Replace Clientside
@@ -324,6 +338,24 @@
ReindexMods();
}
+ private async Task UnloadAllMods()
+ {
+ foreach (var modId in Instance.Mods.Select(m => m.SteamModId).ToList())
+ await RemoveMod(modId);
+
+ BuildDragItems();
+ StateHasChanged();
+ }
+
+ private async Task LoadAllAvailableMods()
+ {
+ foreach (var mod in UnassignedMods.OrderBy(m => m.Name).ToList())
+ await AddMod(mod.Id, "client");
+
+ BuildDragItems();
+ StateHasChanged();
+ }
+
private void ReindexMods()
{
int wi = 0; foreach (var m in Instance.Mods.Where(m => !m.IsClientSide && !m.IsServerSide).OrderBy(m => m.LoadOrder)) m.LoadOrder = wi++;
@@ -376,9 +408,7 @@
_parsedPresetMods.Add((wid, displayName));
}
- _presetSelectedIds = [.. _parsedPresetMods
- .Where(p => !Instance.Mods.Any(m => m.SteamMod?.WorkshopId == p.WorkshopId))
- .Select(p => p.WorkshopId)];
+ _presetSelectedIds = [.. _parsedPresetMods.Select(p => p.WorkshopId)];
StateHasChanged();
}
@@ -391,64 +421,56 @@
private bool? GetPresetSelectAllState()
{
- var newIds = _parsedPresetMods
- .Where(p => !Instance.Mods.Any(m => m.SteamMod?.WorkshopId == p.WorkshopId))
- .Select(p => p.WorkshopId)
- .ToList();
- if (newIds.Count == 0) return false;
- int selected = newIds.Count(id => _presetSelectedIds.Contains(id));
+ var presetIds = _parsedPresetMods.Select(p => p.WorkshopId).ToList();
+ if (presetIds.Count == 0) return false;
+ int selected = presetIds.Count(id => _presetSelectedIds.Contains(id));
if (selected == 0) return false;
- return selected == newIds.Count ? true : null;
+ return selected == presetIds.Count ? true : null;
}
private void ToggleSelectAllPresetMods(bool? value)
{
- var newIds = _parsedPresetMods
- .Where(p => !Instance.Mods.Any(m => m.SteamMod?.WorkshopId == p.WorkshopId))
- .Select(p => p.WorkshopId);
+ var presetIds = _parsedPresetMods.Select(p => p.WorkshopId);
if (value == true)
- foreach (var id in newIds) _presetSelectedIds.Add(id);
+ foreach (var id in presetIds) _presetSelectedIds.Add(id);
else
- foreach (var id in newIds) _presetSelectedIds.Remove(id);
+ foreach (var id in presetIds) _presetSelectedIds.Remove(id);
}
private async Task ImportPresetToClient()
{
_isImportingPreset = true;
- _presetImportTotal = _presetSelectedIds.Count;
+ var selectedPresetMods = _parsedPresetMods
+ .Where(p => _presetSelectedIds.Contains(p.WorkshopId))
+ .GroupBy(p => p.WorkshopId)
+ .Select(g => g.First())
+ .ToList();
+ var selectedWorkshopIds = selectedPresetMods
+ .Select(p => p.WorkshopId)
+ .ToHashSet();
+ var clientsToUnload = Instance.Mods
+ .Where(m => m.IsClientSide && (m.SteamMod == null || !selectedWorkshopIds.Contains(m.SteamMod.WorkshopId)))
+ .ToList();
+
+ _presetImportTotal = selectedPresetMods.Count + clientsToUnload.Count;
_presetImportDone = 0;
StateHasChanged();
- var existingWorkshopIds = Instance.Mods
- .Where(m => m.SteamMod != null)
- .Select(m => m.SteamMod!.WorkshopId)
- .ToHashSet();
+ var desiredClientMods = new List
();
- foreach (var (workshopId, _) in _parsedPresetMods.Where(p => _presetSelectedIds.Contains(p.WorkshopId)))
+ foreach (var (workshopId, _) in selectedPresetMods)
{
try
{
- var mod = await ModService.AddWorkshopModAsync(workshopId);
+ var existingMod = Instance.Mods
+ .FirstOrDefault(m => m.SteamMod?.WorkshopId == workshopId)
+ ?.SteamMod;
+ var mod = existingMod ?? await ModService.AddWorkshopModAsync(workshopId);
if (!_availableMods.Any(m => m.Id == mod.Id))
_availableMods.Add(mod);
- if (!existingWorkshopIds.Contains(workshopId))
- {
- if (!IsNew)
- await ServerService.AddModToInstanceAsync(Instance.Id, mod.Id,
- isClientSide: true, isServerSide: false);
-
- Instance.Mods.Add(new ServerInstanceMod
- {
- SteamModId = mod.Id,
- SteamMod = mod,
- IsClientSide = true,
- IsServerSide = false,
- LoadOrder = Instance.Mods.Count
- });
- existingWorkshopIds.Add(workshopId);
- }
+ desiredClientMods.Add(mod);
}
catch { /* skip mods that fail to resolve */ }
@@ -456,6 +478,68 @@
StateHasChanged();
}
+ foreach (var clientMod in clientsToUnload)
+ {
+ if (clientMod.IsServerSide)
+ {
+ clientMod.IsClientSide = false;
+ if (!IsNew)
+ await ServerService.UpdateModFlagsAsync(
+ Instance.Id,
+ clientMod.SteamModId,
+ isClientSide: false,
+ isServerSide: true);
+ }
+ else
+ {
+ await RemoveMod(clientMod.SteamModId);
+ }
+
+ _presetImportDone++;
+ StateHasChanged();
+ }
+
+ for (var index = 0; index < desiredClientMods.Count; index++)
+ {
+ var mod = desiredClientMods[index];
+ var existingLink = Instance.Mods.FirstOrDefault(m => m.SteamModId == mod.Id);
+ if (existingLink == null)
+ {
+ if (!IsNew)
+ await ServerService.AddModToInstanceAsync(
+ Instance.Id,
+ mod.Id,
+ index,
+ isClientSide: true,
+ isServerSide: false);
+
+ Instance.Mods.Add(new ServerInstanceMod
+ {
+ SteamModId = mod.Id,
+ SteamMod = mod,
+ IsClientSide = true,
+ IsServerSide = false,
+ LoadOrder = index
+ });
+ }
+ else
+ {
+ existingLink.IsClientSide = true;
+ existingLink.IsServerSide = false;
+ existingLink.LoadOrder = index;
+
+ if (!IsNew)
+ {
+ await ServerService.UpdateModFlagsAsync(
+ Instance.Id,
+ mod.Id,
+ isClientSide: true,
+ isServerSide: false);
+ await ServerService.UpdateModLoadOrderAsync(Instance.Id, mod.Id, index);
+ }
+ }
+ }
+
ReindexMods();
BuildDragItems();
ClosePresetImportDialog();
diff --git a/src/KAST.UI/Components/Pages/Servers.razor b/src/KAST.UI/Components/Pages/Servers.razor
index 8b80717..1b7fd36 100644
--- a/src/KAST.UI/Components/Pages/Servers.razor
+++ b/src/KAST.UI/Components/Pages/Servers.razor
@@ -1,5 +1,6 @@
@page "/servers"
@inject IServerInstanceService ServerService
+@inject IOutputSanitizer OutputSanitizer
@inject ISnackbar Snackbar
@inject NavigationManager Nav
@@ -89,20 +90,20 @@ else
private async Task StartInstance(int id)
{
try { await ServerService.StartInstanceAsync(id); Nav.NavigateTo($"/servers/{id}"); }
- catch (Exception ex) { Snackbar.Add($"Failed to start: {ex.Message}", Severity.Error); await RefreshInstances(); }
+ catch (Exception ex) { Snackbar.Add($"Failed to start: {OutputSanitizer.SanitizeException(ex)}", Severity.Error); await RefreshInstances(); }
}
private async Task StopInstance(int id)
{
try { await ServerService.StopInstanceAsync(id); Snackbar.Add("Server stopped", Severity.Success); }
- catch (Exception ex) { Snackbar.Add($"Failed to stop: {ex.Message}", Severity.Error); }
+ catch (Exception ex) { Snackbar.Add($"Failed to stop: {OutputSanitizer.SanitizeException(ex)}", Severity.Error); }
finally { await RefreshInstances(); }
}
private async Task RestartInstance(int id)
{
try { await ServerService.RestartInstanceAsync(id); Snackbar.Add("Server restarted", Severity.Success); }
- catch (Exception ex) { Snackbar.Add($"Failed to restart: {ex.Message}", Severity.Error); }
+ catch (Exception ex) { Snackbar.Add($"Failed to restart: {OutputSanitizer.SanitizeException(ex)}", Severity.Error); }
finally { await RefreshInstances(); }
}
diff --git a/src/KAST.UI/Components/Pages/Settings.razor b/src/KAST.UI/Components/Pages/Settings.razor
index 6aa3ce0..81cd86c 100644
--- a/src/KAST.UI/Components/Pages/Settings.razor
+++ b/src/KAST.UI/Components/Pages/Settings.razor
@@ -3,6 +3,7 @@
@inject IApiKeyService ApiKeyService
@inject ISettingsService SettingsService
@inject ISteamService SteamService
+@inject IOutputSanitizer OutputSanitizer
@inject ISnackbar Snackbar
@inject ThemeService ThemeService
@inject NavigationManager Navigation
@@ -350,7 +351,7 @@
if (!string.IsNullOrEmpty(session.ErrorMessage))
{
_loggingIn = false;
- Snackbar.Add($"QR error: {session.ErrorMessage}", Severity.Error);
+ Snackbar.Add($"QR error: {OutputSanitizer.Sanitize(session.ErrorMessage)}", Severity.Error);
StateHasChanged();
return;
}
@@ -383,7 +384,7 @@
if (!string.IsNullOrEmpty(session.ErrorMessage))
{
_loggingIn = false;
- Snackbar.Add($"Credential login error: {session.ErrorMessage}", Severity.Error);
+ Snackbar.Add($"Credential login error: {OutputSanitizer.Sanitize(session.ErrorMessage)}", Severity.Error);
StateHasChanged();
return;
}
@@ -494,10 +495,10 @@
{
if (!SteamService.IsConnected)
{ _benchmarkStatus = "Connecting to Steam..."; StateHasChanged(); await SteamService.LoginAnonymousAsync(); }
- var log = new Progress(msg => InvokeAsync(() => { _benchmarkStatus = msg; StateHasChanged(); }));
+ var log = new Progress(msg => InvokeAsync(() => { _benchmarkStatus = OutputSanitizer.Sanitize(msg); StateHasChanged(); }));
_benchmarkResults = await SteamService.BenchmarkDownloadAsync(log);
}
- catch (Exception ex) { Snackbar.Add($"Benchmark failed: {ex.Message}", Severity.Error); }
+ catch (Exception ex) { Snackbar.Add($"Benchmark failed: {OutputSanitizer.SanitizeException(ex)}", Severity.Error); }
finally { _benchmarkRunning = false; _benchmarkStatus = null; StateHasChanged(); }
}
diff --git a/src/KAST.UI/KAST.UI.csproj b/src/KAST.UI/KAST.UI.csproj
index 7a10f8f..faf03f1 100644
--- a/src/KAST.UI/KAST.UI.csproj
+++ b/src/KAST.UI/KAST.UI.csproj
@@ -18,6 +18,7 @@
all
+
@@ -25,4 +26,4 @@
-
\ No newline at end of file
+
diff --git a/src/KAST.UI/Program.cs b/src/KAST.UI/Program.cs
index 1a0c60b..afda877 100644
--- a/src/KAST.UI/Program.cs
+++ b/src/KAST.UI/Program.cs
@@ -2,6 +2,7 @@
using KAST.Infrastructure;
using KAST.Infrastructure.Data;
using KAST.Infrastructure.Steam;
+using KAST.Infrastructure.Services;
using KAST.Infrastructure.Telemetry;
using KAST.UI.Api;
using KAST.UI.Components;
@@ -15,11 +16,21 @@
using ModStatus = KAST.Core.Enums.ModStatus;
var builder = WebApplication.CreateBuilder(args);
+var contentRoot = builder.Environment.ContentRootPath;
+var outputSanitizer = new OutputSanitizer(
+ new OutputSanitizer.VirtualPathRoot(contentRoot, "KAST"),
+ new OutputSanitizer.VirtualPathRoot(ResolveConfiguredPath(contentRoot, builder.Configuration["Kast:ModsDirectory"] ?? "./mods"), "mods"),
+ new OutputSanitizer.VirtualPathRoot(ResolveConfiguredPath(contentRoot, builder.Configuration["Kast:ServersDirectory"] ?? "./servers"), "server"));
+builder.Services.AddSingleton(outputSanitizer);
+builder.Host.UseWindowsService(options =>
+{
+ options.ServiceName = "KAST Panel";
+});
// ── In-memory log capture (UI console) ──────────────────────────────────────
var kastLogStore = new KastLogStore();
builder.Services.AddSingleton(kastLogStore);
-builder.Logging.AddProvider(new KastLoggerProvider(kastLogStore));
+builder.Logging.AddProvider(new KastLoggerProvider(kastLogStore, outputSanitizer));
// ── Health checks ────────────────────────────────────────────────────────────
builder.Services.AddHealthChecks()
@@ -69,7 +80,7 @@
var serviceName = telemetry["ServiceName"] ?? KastActivitySources.ServiceName;
// Attach ILogger calls as span events so they appear inside traces in Jaeger
- builder.Logging.AddProvider(new ActivityEventLoggerProvider());
+ builder.Logging.AddProvider(new ActivityEventLoggerProvider(outputSanitizer));
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
@@ -194,3 +205,6 @@
app.Run();
+static string ResolveConfiguredPath(string contentRoot, string path) =>
+ Path.GetFullPath(Path.IsPathRooted(path) ? path : Path.Combine(contentRoot, path));
+
diff --git a/src/KAST.UI/Services/ActivityEventLoggerProvider.cs b/src/KAST.UI/Services/ActivityEventLoggerProvider.cs
index 7ea9d92..8004d20 100644
--- a/src/KAST.UI/Services/ActivityEventLoggerProvider.cs
+++ b/src/KAST.UI/Services/ActivityEventLoggerProvider.cs
@@ -1,4 +1,5 @@
using System.Diagnostics;
+using KAST.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace KAST.UI.Services;
@@ -8,16 +9,16 @@ namespace KAST.UI.Services;
/// that log messages are visible inside traces in Jaeger.
/// Only wires up when there is an active span; has no effect otherwise.
///
-public sealed class ActivityEventLoggerProvider : ILoggerProvider
+public sealed class ActivityEventLoggerProvider(IOutputSanitizer sanitizer) : ILoggerProvider
{
- public ILogger CreateLogger(string categoryName) => new ActivityEventLogger(categoryName);
+ public ILogger CreateLogger(string categoryName) => new ActivityEventLogger(categoryName, sanitizer);
public void Dispose()
{
// No unmanaged resources to release.
}
}
-file sealed class ActivityEventLogger(string categoryName) : ILogger
+file sealed class ActivityEventLogger(string categoryName, IOutputSanitizer sanitizer) : ILogger
{
// No span = no overhead; never block callers with actual I/O in this path.
public bool IsEnabled(LogLevel logLevel) =>
@@ -34,14 +35,14 @@ public void Log(LogLevel logLevel, EventId eventId, TState state,
var tags = new ActivityTagsCollection
{
["log.severity"] = logLevel.ToString(),
- ["log.message"] = formatter(state, exception),
+ ["log.message"] = sanitizer.Sanitize(formatter(state, exception)),
["log.category"] = categoryName
};
if (exception is not null)
{
tags["exception.type"] = exception.GetType().Name;
- tags["exception.message"] = exception.Message;
+ tags["exception.message"] = sanitizer.Sanitize(exception.Message);
}
activity.AddEvent(new ActivityEvent("log", tags: tags));
diff --git a/src/KAST.UI/Services/KastLoggerProvider.cs b/src/KAST.UI/Services/KastLoggerProvider.cs
index 4f3897d..f9b903c 100644
--- a/src/KAST.UI/Services/KastLoggerProvider.cs
+++ b/src/KAST.UI/Services/KastLoggerProvider.cs
@@ -1,10 +1,12 @@
+using KAST.Core.Interfaces;
+
namespace KAST.UI.Services;
[ProviderAlias("KastInMemory")]
-public sealed class KastLoggerProvider(KastLogStore store) : ILoggerProvider
+public sealed class KastLoggerProvider(KastLogStore store, IOutputSanitizer sanitizer) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) =>
- new KastLogger(store, categoryName);
+ new KastLogger(store, sanitizer, categoryName);
public void Dispose()
{
@@ -12,7 +14,7 @@ public void Dispose()
}
}
-internal sealed class KastLogger(KastLogStore store, string category) : ILogger
+internal sealed class KastLogger(KastLogStore store, IOutputSanitizer sanitizer, string category) : ILogger
{
public bool IsEnabled(LogLevel logLevel)
{
@@ -32,9 +34,9 @@ public void Log(
if (!IsEnabled(logLevel))
return;
- var message = formatter(state, exception);
- if (exception != null)
- message += $"\n{exception}";
+ var message = sanitizer.Sanitize(formatter(state, exception));
+ if (exception != null)
+ message += $"\n{sanitizer.SanitizeException(exception)}";
store.Add(new AppLogEntry(DateTime.Now, logLevel, category, message));
}
diff --git a/src/KAST.UI/Services/SignalREventBroadcaster.cs b/src/KAST.UI/Services/SignalREventBroadcaster.cs
index 2030d5f..0de0009 100644
--- a/src/KAST.UI/Services/SignalREventBroadcaster.cs
+++ b/src/KAST.UI/Services/SignalREventBroadcaster.cs
@@ -11,7 +11,8 @@ namespace KAST.UI.Services;
public class SignalREventBroadcaster(
IHubContext downloadHub,
IHubContext monitoringHub,
- ServerConsoleStore consoleStore) : IAppEventBroadcaster
+ ServerConsoleStore consoleStore,
+ IOutputSanitizer sanitizer) : IAppEventBroadcaster
{
public event Action? OnModDownloadProgress;
public event Action? OnModStatusChanged;
@@ -39,7 +40,8 @@ public Task BroadcastInstanceMetricsAsync(InstanceMetricsUpdatedEvent metrics)
public Task BroadcastLogEntryAsync(LogEntryEvent logEntry)
{
- consoleStore.Add(logEntry);
- return MonitoringHub.BroadcastLogEntry(monitoringHub, logEntry);
+ var safeLogEntry = logEntry with { Line = sanitizer.Sanitize(logEntry.Line) };
+ consoleStore.Add(safeLogEntry);
+ return MonitoringHub.BroadcastLogEntry(monitoringHub, safeLogEntry);
}
}
diff --git a/tests/KAST.Tests/CdnServerPoolTests.cs b/tests/KAST.Tests/CdnServerPoolTests.cs
index d61506b..4d866f3 100644
--- a/tests/KAST.Tests/CdnServerPoolTests.cs
+++ b/tests/KAST.Tests/CdnServerPoolTests.cs
@@ -1,3 +1,4 @@
+using KAST.Infrastructure.Services;
using KAST.Infrastructure.Steam;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -19,7 +20,7 @@ public void ReturnServer_NonFaulty_CanBeRetrievedByGetServer()
Assert.NotNull(steamContent);
var logger = Substitute.For();
- using var pool = new CdnServerPool(steamClient, steamContent!, logger, parent.Token);
+ using var pool = new CdnServerPool(steamClient, steamContent!, logger, new OutputSanitizer(), parent.Token);
var server = CreateServerStub();
pool.ReturnServer(server, isFaulty: false);
@@ -40,7 +41,7 @@ public void ReturnServer_Faulty_IsDiscarded()
Assert.NotNull(steamContent);
var logger = Substitute.For();
- using var pool = new CdnServerPool(steamClient, steamContent!, logger, parent.Token);
+ using var pool = new CdnServerPool(steamClient, steamContent!, logger, new OutputSanitizer(), parent.Token);
var server = CreateServerStub();
pool.ReturnServer(server, isFaulty: true);
@@ -62,7 +63,7 @@ public void GetServer_WhenEmpty_AndTokenCancelled_ThrowsOperationCanceled()
Assert.NotNull(steamContent);
var logger = Substitute.For();
- using var pool = new CdnServerPool(steamClient, steamContent!, logger, parent.Token);
+ using var pool = new CdnServerPool(steamClient, steamContent!, logger, new OutputSanitizer(), parent.Token);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
diff --git a/tests/KAST.Tests/ContentOrchestratorTests.cs b/tests/KAST.Tests/ContentOrchestratorTests.cs
index ec7cd62..e073b41 100644
--- a/tests/KAST.Tests/ContentOrchestratorTests.cs
+++ b/tests/KAST.Tests/ContentOrchestratorTests.cs
@@ -3,6 +3,7 @@
using KAST.Core.Models;
using KAST.Infrastructure.Data;
using KAST.Infrastructure.Services.Content;
+using KAST.Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -286,7 +287,7 @@ private static ContentOrchestrator BuildOrchestrator(IServiceProvider provider,
var tracker = new ContentProgressTracker();
var scopeFactory = provider.GetRequiredService();
var logger = Substitute.For>();
- return new ContentOrchestrator(scopeFactory, tracker, installers, logger);
+ return new ContentOrchestrator(scopeFactory, tracker, installers, logger, new OutputSanitizer());
}
private static ServiceProvider BuildServiceProvider(string? dbName = null)
diff --git a/tests/KAST.Tests/KastApiModsEndpointsTests.cs b/tests/KAST.Tests/KastApiModsEndpointsTests.cs
index e54f6fa..117e784 100644
--- a/tests/KAST.Tests/KastApiModsEndpointsTests.cs
+++ b/tests/KAST.Tests/KastApiModsEndpointsTests.cs
@@ -5,6 +5,7 @@
using KAST.Core.Interfaces;
using KAST.Core.Models;
using KAST.UI.Api;
+using KAST.Infrastructure.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
@@ -238,6 +239,7 @@ private static async Task CreateApiAppAsync(
builder.Services.AddSingleton(settingsService ?? BuildDefaultSettingsService());
builder.Services.AddSingleton(fileSystemService ?? Substitute.For());
builder.Services.AddSingleton(broadcaster ?? BuildDefaultBroadcaster());
+ builder.Services.AddSingleton();
var app = builder.Build();
app.MapGroup("/api").MapKastApi();
diff --git a/tests/KAST.Tests/LocalModInstallerTests.cs b/tests/KAST.Tests/LocalModInstallerTests.cs
index c0a7cb1..d151471 100644
--- a/tests/KAST.Tests/LocalModInstallerTests.cs
+++ b/tests/KAST.Tests/LocalModInstallerTests.cs
@@ -1,6 +1,7 @@
using KAST.Core.Enums;
using KAST.Core.Interfaces;
using KAST.Core.Models;
+using KAST.Infrastructure.Services;
using KAST.Infrastructure.Services.Content;
using NSubstitute;
@@ -10,6 +11,8 @@ public class LocalModInstallerTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"kast-local-installer-tests-{Guid.NewGuid():N}");
+ private static OutputSanitizer Sanitizer() => new();
+
public LocalModInstallerTests()
{
Directory.CreateDirectory(_root);
@@ -31,7 +34,7 @@ public void Dispose()
public void PlanSteps_Zip_IncludesExtractAndSize()
{
var fs = Substitute.For();
- var sut = new LocalModInstaller(fs);
+ var sut = new LocalModInstaller(fs, Sanitizer());
var steps = sut.PlanSteps(new ContentInstallRequest
{
@@ -49,7 +52,7 @@ public void PlanSteps_Zip_IncludesExtractAndSize()
public void PlanSteps_Folder_IncludesOnlySize()
{
var fs = Substitute.For();
- var sut = new LocalModInstaller(fs);
+ var sut = new LocalModInstaller(fs, Sanitizer());
var steps = sut.PlanSteps(new ContentInstallRequest
{
@@ -74,7 +77,7 @@ public async Task InstallAsync_ZipPath_ExtractsAndCompletesAllSteps()
return Task.CompletedTask;
});
- var sut = new LocalModInstaller(fs);
+ var sut = new LocalModInstaller(fs, Sanitizer());
var request = new ContentInstallRequest
{
@@ -104,7 +107,7 @@ public async Task InstallAsync_FolderPath_CompletesSizeStep()
var fs = Substitute.For();
fs.GetDirectorySize(Arg.Any()).Returns(2048L);
- var sut = new LocalModInstaller(fs);
+ var sut = new LocalModInstaller(fs, Sanitizer());
var request = new ContentInstallRequest
{
@@ -135,7 +138,7 @@ public void Validate_ReturnsExpectedChecks_WhenDirectoryExists()
var fs = Substitute.For();
fs.GetDirectorySize(Arg.Any()).Returns(4096L);
- var sut = new LocalModInstaller(fs);
+ var sut = new LocalModInstaller(fs, Sanitizer());
var path = Path.Combine(_root, "mod-dir");
Directory.CreateDirectory(path);
@@ -153,7 +156,7 @@ public void Validate_ReturnsExpectedChecks_WhenDirectoryExists()
public void Validate_WhenDirectoryMissing_ReturnsSingleFailure()
{
var fs = Substitute.For();
- var sut = new LocalModInstaller(fs);
+ var sut = new LocalModInstaller(fs, Sanitizer());
var results = sut.Validate(new ContentInstallRequest
{
diff --git a/tests/KAST.Tests/ModServiceTests.cs b/tests/KAST.Tests/ModServiceTests.cs
index 60dbd33..01c5437 100644
--- a/tests/KAST.Tests/ModServiceTests.cs
+++ b/tests/KAST.Tests/ModServiceTests.cs
@@ -29,7 +29,7 @@ public ModServiceTests()
.Returns(new KastSettings { ModsDirectory = ".KAST_DATA/mods" });
_broadcaster = Substitute.For();
_logger = Substitute.For>();
- _sut = new ModService(_db, _steamService, _settingsService, _broadcaster, _logger);
+ _sut = new ModService(_db, _steamService, _settingsService, _broadcaster, _logger, new OutputSanitizer());
}
~ModServiceTests()
diff --git a/tests/KAST.Tests/OutputSanitizerTests.cs b/tests/KAST.Tests/OutputSanitizerTests.cs
new file mode 100644
index 0000000..0b251af
--- /dev/null
+++ b/tests/KAST.Tests/OutputSanitizerTests.cs
@@ -0,0 +1,221 @@
+using KAST.Infrastructure.Services;
+
+namespace KAST.Tests;
+
+public class OutputSanitizerTests
+{
+ [Theory]
+ [InlineData("Failed to read C:\\servers\\arma\\server.cfg", "server.cfg")]
+ [InlineData("Failed to read C:/servers/arma/server.cfg", "server.cfg")]
+ [InlineData("Failed to read \\\\nas01\\share\\server.cfg", "server.cfg")]
+ [InlineData("Failed to read /home/kast/servers/server.cfg", "server.cfg")]
+ [InlineData("Failed to read ~/kast/servers/server.cfg", "server.cfg")]
+ [InlineData("Failed to read file:///C:/servers/arma/server.cfg", "server.cfg")]
+ public void Sanitize_StripsPathRootsAndKeepsRelativeContext(string message, string expectedRelativePath)
+ {
+ var sanitizer = new OutputSanitizer();
+
+ var sanitized = sanitizer.Sanitize(message);
+
+ Assert.Contains(expectedRelativePath, sanitized);
+ Assert.DoesNotContain("servers/arma", sanitized);
+ Assert.DoesNotContain("share/", sanitized);
+ Assert.DoesNotContain("", sanitized);
+ AssertNoAbsoluteRoot(sanitized);
+ }
+
+ [Fact]
+ public void ToDisplayPath_StripsRootAndPreservesKnownFolderSuffix()
+ {
+ var sanitizer = new OutputSanitizer("D:/games/KAST");
+
+ var displayPath = sanitizer.ToDisplayPath("D:\\games\\KAST\\servers\\alpha\\arma3server_x64.exe");
+
+ Assert.Equal("servers/alpha/arma3server_x64.exe", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Fact]
+ public void Sanitize_RewritesWindowsStackTracePathToRelativeFile()
+ {
+ var sanitizer = new OutputSanitizer();
+ var message = " at KAST.Service.Run() in C:\\src\\KAST\\Service.cs:line 42";
+
+ var sanitized = sanitizer.Sanitize(message);
+
+ Assert.Contains("in Service.cs:line 42", sanitized);
+ AssertNoAbsoluteRoot(sanitized);
+ }
+
+ [Fact]
+ public void SanitizeException_UsesTypeAndSanitizedMessageOnly()
+ {
+ var sanitizer = new OutputSanitizer();
+ var exception = new InvalidOperationException("Could not access C:\\servers\\arma\\server.cfg");
+
+ var sanitized = sanitizer.SanitizeException(exception);
+
+ Assert.StartsWith("InvalidOperationException:", sanitized);
+ Assert.Contains("server.cfg", sanitized);
+ Assert.DoesNotContain("servers/arma", sanitized);
+ AssertNoAbsoluteRoot(sanitized);
+ }
+
+ [Fact]
+ public void ToDisplayPath_StripsRootsForConfiguredKastFolders()
+ {
+ var sanitizer = new OutputSanitizer(
+ new OutputSanitizer.VirtualPathRoot("C:/apps/KAST", "KAST"),
+ new OutputSanitizer.VirtualPathRoot("C:/apps/KAST/.KAST_DATA/mods", "mods"),
+ new OutputSanitizer.VirtualPathRoot("D:/server/Arma3/KAST/server", "server"));
+
+ Assert.Equal("KAST/README.md", sanitizer.ToDisplayPath("C:/apps/KAST/README.md"));
+ Assert.Equal("mods/@ace/addons/ace_main.pbo", sanitizer.ToDisplayPath("C:/apps/KAST/.KAST_DATA/mods/@ace/addons/ace_main.pbo"));
+ Assert.Equal("server/Server1/arma3server_x64.exe", sanitizer.ToDisplayPath("D:/server/Arma3/KAST/server/Server1/arma3server_x64.exe"));
+ }
+
+ [Fact]
+ public void ToDisplayPath_ConfiguredKastDataModsRootMapsToModsVirtualPath()
+ {
+ var sanitizer = new OutputSanitizer(new OutputSanitizer.VirtualPathRoot("G:/KAST/src/KAST.UI/.KAST_DATA/mods", "mods"));
+
+ var displayPath = sanitizer.ToDisplayPath("G:\\KAST\\src\\KAST.UI\\.KAST_DATA\\mods\\463939057");
+
+ Assert.Equal("mods/463939057", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Fact]
+ public void ToDisplayPath_UnknownAbsolutePathOnlyShowsSafeFileName()
+ {
+ var sanitizer = new OutputSanitizer("C:/apps/KAST/.KAST_DATA/mods");
+
+ var displayPath = sanitizer.ToDisplayPath("E:/random/user/private/secret.cfg");
+
+ Assert.Equal("secret.cfg", displayPath);
+ Assert.DoesNotContain("random", displayPath);
+ Assert.DoesNotContain("private", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Fact]
+ public void ToDisplayPath_VirtualRootPreventsTraversalEscapeDisclosure()
+ {
+ var sanitizer = new OutputSanitizer(new OutputSanitizer.VirtualPathRoot("C:/apps/KAST/.KAST_DATA/mods", "mods"));
+
+ var displayPath = sanitizer.ToDisplayPath("C:/apps/KAST/.KAST_DATA/mods/../../private/token.txt");
+
+ Assert.Equal("token.txt", displayPath);
+ Assert.DoesNotContain("private", displayPath);
+ Assert.DoesNotContain("..", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Theory]
+ [InlineData("../secrets/config.json", "secrets/config.json")]
+ [InlineData("mods/@ace/../@cba/addons/cba_main.pbo", "mods/@cba/addons/cba_main.pbo")]
+ public void ToDisplayPath_NormalizesRelativeTraversalWithoutLeakingParentFolders(string input, string expected)
+ {
+ var sanitizer = new OutputSanitizer(
+ "C:/apps/KAST",
+ "C:/apps/KAST/.KAST_DATA/mods",
+ "D:/server/Arma3/KAST/server");
+
+ var displayPath = sanitizer.ToDisplayPath(input);
+
+ Assert.Equal(expected, displayPath);
+ Assert.DoesNotContain("..", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Fact]
+ public void Sanitize_RewritesConfiguredKastFolderPaths()
+ {
+ var sanitizer = new OutputSanitizer(
+ new OutputSanitizer.VirtualPathRoot("C:/apps/KAST", "KAST"),
+ new OutputSanitizer.VirtualPathRoot("C:/apps/KAST/.KAST_DATA/mods", "mods"),
+ new OutputSanitizer.VirtualPathRoot("D:/server/Arma3/KAST/server", "server"));
+ var message = "Read C:/apps/KAST/README.md, C:/apps/KAST/.KAST_DATA/mods/@ace/addons/ace_main.pbo and D:/server/Arma3/KAST/server/Server1/arma3server_x64.exe";
+
+ var sanitized = sanitizer.Sanitize(message);
+
+ Assert.Contains("KAST/README.md", sanitized);
+ Assert.Contains("mods/@ace/addons/ace_main.pbo", sanitized);
+ Assert.Contains("Server1/arma3server_x64.exe", sanitized);
+ Assert.DoesNotContain("Arma3/KAST/server", sanitized);
+ AssertNoAbsoluteRoot(sanitized);
+ }
+
+ [Fact]
+ public void ToDisplayPath_SymlinkTargetInsideVirtualRootsKeepsLexicalDisplayPath()
+ {
+ var sanitizer = new OutputSanitizer(
+ path => path.Equals("D:/server/Arma3/KAST/server/Server1/mods/@ace/addons/ace_main.pbo", StringComparison.OrdinalIgnoreCase)
+ ? "C:/apps/KAST/.KAST_DATA/mods/@ace/addons/ace_main.pbo"
+ : null,
+ new OutputSanitizer.VirtualPathRoot("C:/apps/KAST/.KAST_DATA/mods", "mods"),
+ new OutputSanitizer.VirtualPathRoot("D:/server/Arma3/KAST/server", "server"));
+
+ var displayPath = sanitizer.ToDisplayPath("D:/server/Arma3/KAST/server/Server1/mods/@ace/addons/ace_main.pbo");
+
+ Assert.Equal("server/Server1/mods/@ace/addons/ace_main.pbo", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Fact]
+ public void ToDisplayPath_ExternalSymlinkTargetOutsideVirtualRootsFallsBackToFileName()
+ {
+ var sanitizer = new OutputSanitizer(
+ path => path.Equals("D:/server/Arma3/KAST/server/Server1/mods/@evil/token.txt", StringComparison.OrdinalIgnoreCase)
+ ? "C:/Users/Admin/secrets/token.txt"
+ : null,
+ new OutputSanitizer.VirtualPathRoot("C:/apps/KAST/.KAST_DATA/mods", "mods"),
+ new OutputSanitizer.VirtualPathRoot("D:/server/Arma3/KAST/server", "server"));
+
+ var displayPath = sanitizer.ToDisplayPath("D:/server/Arma3/KAST/server/Server1/mods/@evil/token.txt");
+
+ Assert.Equal("token.txt", displayPath);
+ Assert.DoesNotContain("Users", displayPath);
+ Assert.DoesNotContain("secrets", displayPath);
+ Assert.DoesNotContain("server/Server1", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Fact]
+ public void ToDisplayPath_UnresolvableSymlinkUsesLexicalVirtualRootDisplayPath()
+ {
+ var sanitizer = new OutputSanitizer(
+ _ => throw new IOException("broken link"),
+ new OutputSanitizer.VirtualPathRoot("D:/server/Arma3/KAST/server", "server"));
+
+ var displayPath = sanitizer.ToDisplayPath("D:/server/Arma3/KAST/server/Server1/@ace");
+
+ Assert.Equal("server/Server1/@ace", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ [Fact]
+ public void ToDisplayPath_VirtualRootPrefixCollisionDoesNotMatch()
+ {
+ var sanitizer = new OutputSanitizer(new OutputSanitizer.VirtualPathRoot("C:/apps/KAST", "KAST"));
+
+ var displayPath = sanitizer.ToDisplayPath("C:/apps/KAST_backup/private/secret.txt");
+
+ Assert.Equal("secret.txt", displayPath);
+ Assert.DoesNotContain("KAST_backup", displayPath);
+ Assert.DoesNotContain("private", displayPath);
+ AssertNoAbsoluteRoot(displayPath);
+ }
+
+ private static void AssertNoAbsoluteRoot(string value)
+ {
+ Assert.DoesNotContain("C:\\", value);
+ Assert.DoesNotContain("D:\\", value);
+ Assert.DoesNotContain("C:/", value);
+ Assert.DoesNotContain("D:/", value);
+ Assert.DoesNotContain("\\\\nas01", value);
+ Assert.DoesNotContain("/home/", value);
+ Assert.DoesNotContain("~/", value);
+ Assert.DoesNotContain("file:///", value);
+ }
+}
diff --git a/tests/KAST.Tests/ServerInstallerTests.cs b/tests/KAST.Tests/ServerInstallerTests.cs
index b1584a6..430c995 100644
--- a/tests/KAST.Tests/ServerInstallerTests.cs
+++ b/tests/KAST.Tests/ServerInstallerTests.cs
@@ -1,6 +1,7 @@
using KAST.Core.Enums;
using KAST.Core.Interfaces;
using KAST.Core.Models;
+using KAST.Infrastructure.Services;
using KAST.Infrastructure.Services.Content;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -17,7 +18,7 @@ public void PlanSteps_WhenInstanceMissing_ReturnsBaseOnly()
var steam = Substitute.For();
var fs = Substitute.For();
var logger = Substitute.For>();
- var sut = new ServerInstaller(steam, fs, Substitute.For(), logger);
+ var sut = new ServerInstaller(steam, fs, Substitute.For(), logger, new OutputSanitizer());
var steps = sut.PlanSteps(new ContentInstallRequest
{
@@ -36,7 +37,7 @@ public void PlanSteps_OnlyIncludesEnabledDlcs()
var steam = Substitute.For();
var fs = Substitute.For();
var logger = Substitute.For>();
- var sut = new ServerInstaller(steam, fs, Substitute.For(), logger);
+ var sut = new ServerInstaller(steam, fs, Substitute.For(), logger, new OutputSanitizer());
var instance = new ServerInstance
{
@@ -72,7 +73,7 @@ public async Task InstallAsync_WhenSteamCannotConnect_Throws()
var fs = Substitute.For();
var logger = Substitute.For>();
- var sut = new ServerInstaller(steam, fs, Substitute.For(), logger);
+ var sut = new ServerInstaller(steam, fs, Substitute.For(), logger, new OutputSanitizer());
var instance = new ServerInstance { Id = 10, Name = "Srv" };
var request = new ContentInstallRequest
@@ -101,7 +102,7 @@ public async Task InstallAsync_WhenInstanceMissing_Throws()
var steam = Substitute.For();
var fs = Substitute.For();
var logger = Substitute.For>();
- var sut = new ServerInstaller(steam, fs, Substitute.For(), logger);
+ var sut = new ServerInstaller(steam, fs, Substitute.For(), logger, new OutputSanitizer());
var request = new ContentInstallRequest
{
@@ -140,7 +141,7 @@ public async Task InstallAsync_Connected_DownloadsBaseAndEnabledDlcs()
var fs = Substitute.For();
var logger = Substitute.For>();
- var sut = new ServerInstaller(steam, fs, Substitute.For(), logger);
+ var sut = new ServerInstaller(steam, fs, Substitute.For(), logger, new OutputSanitizer());
var instance = new ServerInstance
{
@@ -192,7 +193,7 @@ public void Validate_WhenInstallMissing_ReturnsSingleFailure()
var steam = Substitute.For();
var fs = Substitute.For();
var logger = Substitute.For>();
- var sut = new ServerInstaller(steam, fs, Substitute.For(), logger);
+ var sut = new ServerInstaller(steam, fs, Substitute.For(), logger, new OutputSanitizer());
var results = sut.Validate(new ContentInstallRequest
{
@@ -230,7 +231,7 @@ public void Validate_WhenInstallPresent_ReturnsCoreChecks()
var steam = Substitute.For();
var fs = Substitute.For();
var logger = Substitute.For>();
- var sut = new ServerInstaller(steam, fs, Substitute.For(), logger);
+ var sut = new ServerInstaller(steam, fs, Substitute.For(), logger, new OutputSanitizer());
var results = sut.Validate(new ContentInstallRequest
{
diff --git a/tests/KAST.Tests/ServerInstanceServiceTests.cs b/tests/KAST.Tests/ServerInstanceServiceTests.cs
index 31200a2..11774f3 100644
--- a/tests/KAST.Tests/ServerInstanceServiceTests.cs
+++ b/tests/KAST.Tests/ServerInstanceServiceTests.cs
@@ -25,7 +25,7 @@ public ServerInstanceServiceTests()
_processManager = Substitute.For();
_broadcaster = Substitute.For();
_logger = Substitute.For>();
- _sut = new ServerInstanceService(_db, _processManager, _broadcaster, _logger);
+ _sut = new ServerInstanceService(_db, _processManager, _broadcaster, _logger, new OutputSanitizer());
}
public void Dispose()
@@ -199,6 +199,54 @@ await _broadcaster.Received(2)
.BroadcastServerStatusChangedAsync(Arg.Any());
}
+ [Fact]
+ public void GetCommandLine_UsesAbsolutePerInstancePathsForArmaPathArguments()
+ {
+ var relativeInstallPath = Path.Combine("servers", "Server-20260528-202725");
+ var instance = new ServerInstance
+ {
+ Id = 2,
+ InstallPath = relativeInstallPath,
+ Port = 2302,
+ ServerCfgContent = "BattlEye = 0;",
+ ArmaProfileContent = "class DifficultyPresets {};",
+ BasicCfgContent = """
+ // KAST Basic Server Configuration
+
+ viewDistance = 2000;
+ terrainGrid = 25;
+ MaxMsgSend = 128;
+ MaxSizeGuaranteed = 512;
+ MaxSizeNonguaranteed = 256;
+ MinBandwidth = 131072;
+ MaxBandwidth = 10000000000;
+ MinErrorToSend = 0.001;
+ MinErrorToSendNear = 0.01;
+ MaxCustomFileSize = 1024;
+ class sockets
+ {
+ maxPacketSize = 1400;
+ };
+ """
+ };
+
+ var commandLine = _sut.GetCommandLine(instance);
+
+ var fullInstallPath = Path.GetFullPath(relativeInstallPath, AppContext.BaseDirectory);
+ var configDir = Path.Combine(fullInstallPath, "KAST", instance.Id.ToString());
+ var executableName = OperatingSystem.IsWindows()
+ ? "arma3server_x64.exe"
+ : "arma3server_x64";
+
+ Assert.StartsWith(Path.Combine(fullInstallPath, executableName), commandLine);
+ if (!OperatingSystem.IsLinux())
+ Assert.Contains($"\"-profiles={configDir}\"", commandLine);
+ Assert.Contains($"\"-config={Path.Combine(configDir, "server.cfg")}\"", commandLine);
+ Assert.Contains($"\"-cfg={Path.Combine(configDir, "basic.cfg")}\"", commandLine);
+ Assert.DoesNotContain($"-config={relativeInstallPath}", commandLine);
+ Assert.DoesNotContain($"-cfg={relativeInstallPath}", commandLine);
+ }
+
[Fact]
public async Task StartInstance_ProcessFails_SetsCrashedStatus()
{
diff --git a/tests/KAST.Tests/SteamModInstallerTests.cs b/tests/KAST.Tests/SteamModInstallerTests.cs
index 9d1a50f..729cc97 100644
--- a/tests/KAST.Tests/SteamModInstallerTests.cs
+++ b/tests/KAST.Tests/SteamModInstallerTests.cs
@@ -2,6 +2,7 @@
using KAST.Core.Interfaces;
using KAST.Core.Models;
using KAST.Infrastructure.Services.Content;
+using KAST.Infrastructure.Services;
using NSubstitute;
namespace KAST.Tests;
@@ -13,7 +14,7 @@ public void PlanSteps_ReturnsDownloadThenSize()
{
var steam = Substitute.For();
var fs = Substitute.For();
- var sut = new SteamModInstaller(steam, fs);
+ var sut = new SteamModInstaller(steam, fs, new OutputSanitizer());
var steps = sut.PlanSteps(new ContentInstallRequest
{
@@ -35,7 +36,7 @@ public async Task InstallAsync_WhenNotConnectedAndLoginFails_Throws()
steam.LoginAnonymousAsync(Arg.Any()).Returns(Task.FromResult(false));
var fs = Substitute.For();
- var sut = new SteamModInstaller(steam, fs);
+ var sut = new SteamModInstaller(steam, fs, new OutputSanitizer());
var request = new ContentInstallRequest
{
@@ -71,7 +72,7 @@ public async Task InstallAsync_WhenConnected_DownloadsAndCompletesSteps()
var fs = Substitute.For();
fs.GetDirectorySize("/tmp/mod").Returns(8192L);
- var sut = new SteamModInstaller(steam, fs);
+ var sut = new SteamModInstaller(steam, fs, new OutputSanitizer());
var request = new ContentInstallRequest
{
@@ -111,7 +112,7 @@ public async Task InstallAsync_WhenLoginSucceeds_ContinuesToDownload()
var fs = Substitute.For();
fs.GetDirectorySize("/tmp/mod").Returns(1234L);
- var sut = new SteamModInstaller(steam, fs);
+ var sut = new SteamModInstaller(steam, fs, new OutputSanitizer());
var request = new ContentInstallRequest
{
Type = ContentType.SteamMod,
@@ -139,7 +140,7 @@ public void Validate_ReturnsFailureWhenMissing()
{
var steam = Substitute.For();
var fs = Substitute.For();
- var sut = new SteamModInstaller(steam, fs);
+ var sut = new SteamModInstaller(steam, fs, new OutputSanitizer());
var results = sut.Validate(new ContentInstallRequest
{
@@ -161,7 +162,7 @@ public void Validate_WhenDirectoryExists_ReturnsSizeCheck()
var steam = Substitute.For();
var fs = Substitute.For();
fs.GetDirectorySize(path).Returns(5555L);
- var sut = new SteamModInstaller(steam, fs);
+ var sut = new SteamModInstaller(steam, fs, new OutputSanitizer());
var results = sut.Validate(new ContentInstallRequest
{
diff --git a/tests/KAST.Tests/SteamWebApiClientTests.cs b/tests/KAST.Tests/SteamWebApiClientTests.cs
index ffbc07b..a43a9df 100644
--- a/tests/KAST.Tests/SteamWebApiClientTests.cs
+++ b/tests/KAST.Tests/SteamWebApiClientTests.cs
@@ -1,5 +1,6 @@
using System.Net;
using System.Text;
+using KAST.Infrastructure.Services;
using KAST.Infrastructure.Steam;
using Microsoft.Extensions.Logging;
using NSubstitute;
@@ -14,7 +15,7 @@ public async Task GetPublishedFileDetailsBatchAsync_EmptyIds_ReturnsEmptyWithout
var handler = new RecordingHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
var client = new HttpClient(handler);
- var sut = new SteamWebApiClient(client, Substitute.For>());
+ var sut = new SteamWebApiClient(client, Substitute.For>(), new OutputSanitizer());
var result = await sut.GetPublishedFileDetailsBatchAsync([]);
@@ -67,7 +68,7 @@ public async Task GetPublishedFileDetailsBatchAsync_ParsesAndMapsResponse()
});
var client = new HttpClient(handler);
- var sut = new SteamWebApiClient(client, Substitute.For>());
+ var sut = new SteamWebApiClient(client, Substitute.For>(), new OutputSanitizer());
var result = await sut.GetPublishedFileDetailsBatchAsync([333310405]);
@@ -110,7 +111,7 @@ public async Task GetPublishedFileDetailsAsync_NoValidResult_ReturnsNull()
}));
var client = new HttpClient(handler);
- var sut = new SteamWebApiClient(client, Substitute.For>());
+ var sut = new SteamWebApiClient(client, Substitute.For>(), new OutputSanitizer());
var result = await sut.GetPublishedFileDetailsAsync(123);
@@ -123,7 +124,7 @@ public async Task GetPublishedFileDetailsBatchAsync_NonSuccessStatus_Throws()
var handler = new RecordingHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway)));
var client = new HttpClient(handler);
- var sut = new SteamWebApiClient(client, Substitute.For>());
+ var sut = new SteamWebApiClient(client, Substitute.For>(), new OutputSanitizer());
await Assert.ThrowsAsync(() =>
sut.GetPublishedFileDetailsBatchAsync([333310405]));
@@ -154,7 +155,7 @@ public async Task DiscoverCdnServerAsync_PicksHttpsServerAndPrefersVHost()
});
var client = new HttpClient(handler);
- var sut = new SteamWebApiClient(client, Substitute.For>());
+ var sut = new SteamWebApiClient(client, Substitute.For>(), new OutputSanitizer());
var host = await sut.DiscoverCdnServerAsync();
@@ -166,7 +167,7 @@ public async Task DiscoverCdnServerAsync_OnHttpFailure_ReturnsNull()
{
var handler = new RecordingHandler((_, _) => throw new HttpRequestException("boom"));
var client = new HttpClient(handler);
- var sut = new SteamWebApiClient(client, Substitute.For>());
+ var sut = new SteamWebApiClient(client, Substitute.For>(), new OutputSanitizer());
var host = await sut.DiscoverCdnServerAsync();