Files
MBN_STOCK_WEBVIEW/tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/LegacyOperatorSettingsTests.cs

494 lines
19 KiB
C#

using System.Text;
using System.Text.Json;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorSettingsTests
{
private static readonly Encoding Cp949 = CreateCp949Encoding();
[Fact]
public void DefaultPath_IsTheBoundedLocalApplicationConfigurationPath()
{
var expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"Config",
"runtime-folders.local.json");
Assert.Equal(expected, LegacyOperatorSettingsStore.DefaultPath);
Assert.True(Path.IsPathFullyQualified(LegacyOperatorSettingsStore.DefaultPath));
}
[Fact]
public void SceneValidation_RequiresOnlyTheBaselineSceneAndReturnsAnAbbreviation()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
sceneDirectory + Path.DirectorySeparatorChar);
Assert.True(result.IsValid);
Assert.Equal(Path.GetFullPath(sceneDirectory), result.Folder!.CanonicalPath);
Assert.Equal("baseline", File.ReadAllText(
Path.Combine(result.Folder.CanonicalPath, "5001.t2s")));
Assert.Contains("Cuts", result.Folder.AbbreviatedPath);
Assert.InRange(
result.Folder.AbbreviatedPath.Length,
1,
LegacyOperatorFolderValidator.DefaultDisplayCharacters);
Assert.False(result.Folder.DatabaseIniDetected);
Assert.Null(result.WarningMessage);
}
[Fact]
public void SceneValidation_MissingBaselineFailsWithoutLeakingThePath()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("sensitive-design-folder");
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
sceneDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileMissing,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, sceneDirectory);
}
[Fact]
public void BackgroundValidation_AcceptsAnEmptyFixedDriveDirectory()
{
using var directory = new TemporaryDirectory();
var backgroundDirectory = directory.CreateDirectory("Background");
Assert.True(LegacyOperatorFolderValidator.TryValidate(
LegacyOperatorSettingsFolderKind.Background,
backgroundDirectory,
out var folder,
out var warning));
Assert.Equal(Path.GetFullPath(backgroundDirectory), folder!.CanonicalPath);
Assert.Null(warning);
}
[Fact]
public void ResourceValidation_ParsesEveryClosedUiIniAndOnlyFlagsDatabaseIni()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
File.WriteAllText(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"),
"secret-content-that-must-not-be-returned");
var stockMenu = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.False(stockMenu.UsesBuiltInCatalog);
var uiCatalogs = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.Empty(uiCatalogs.Warnings);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.True(result.IsValid, $"{result.Failure}: {result.WarningMessage}");
Assert.True(result.Folder!.DatabaseIniDetected);
Assert.DoesNotContain("secret", result.Folder.AbbreviatedPath,
StringComparison.OrdinalIgnoreCase);
Assert.Null(result.WarningMessage);
}
[Fact]
public void ResourceValidation_InvalidClosedIniFailsWithoutPartialAcceptance()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("private-runtime-resources");
WriteValidResourceFiles(resourceDirectory);
WriteCp949(
Path.Combine(resourceDirectory, "종목.ini"),
["[종목]", "_않은_컷"]);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
}
[Fact]
public void ResourceValidation_RejectsAnUnsafeDatabaseIniWithoutReadingIt()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
Directory.CreateDirectory(Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileUnsafe,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
}
[Fact]
public void Validation_RejectsRelativeMissingRootAndControlContainingPaths()
{
using var directory = new TemporaryDirectory();
var missing = Path.Combine(directory.Path, "missing-private-folder");
string?[] candidates =
[
null,
string.Empty,
"relative-folder",
missing,
Path.GetPathRoot(directory.Path),
directory.Path + "\nprivate"
];
foreach (var candidate in candidates)
{
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Background,
candidate);
Assert.False(result.IsValid);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, missing);
}
}
[Fact]
public void Validation_RejectsAReparsePointInTheDirectoryAncestry_WhenSupported()
{
using var directory = new TemporaryDirectory();
var target = directory.CreateDirectory("target");
var link = Path.Combine(directory.Path, "link");
try
{
Directory.CreateSymbolicLink(link, target);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or
PlatformNotSupportedException or NotSupportedException)
{
return;
}
try
{
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Background,
link);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.ReparsePoint,
result.Failure);
}
finally
{
Directory.Delete(link);
}
}
[Fact]
public void Load_MissingFileUsesDefaultsWithoutWarning()
{
using var directory = new TemporaryDirectory();
var store = new LegacyOperatorSettingsStore(
Path.Combine(directory.Path, "Config", "runtime-folders.local.json"));
var result = store.Load();
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
Assert.False(result.LoadedFromDisk);
Assert.Equal(LegacyOperatorSettingsStoreFailure.None, result.Failure);
Assert.Null(result.WarningMessage);
}
[Fact]
public void SaveAndLoad_RoundTripsOnlyTheVersionedClosedSchemaAtomically()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
var backgroundDirectory = directory.CreateDirectory("Background");
var configurationPath = Path.Combine(
directory.Path,
"Config",
"runtime-folders.local.json");
var store = new LegacyOperatorSettingsStore(configurationPath);
var settings = new LegacyOperatorSettings(
sceneDirectory,
resourceDirectory,
backgroundDirectory,
NavigationExpanded: false);
var firstSave = store.Save(settings);
var secondSave = store.Save(settings with { NavigationExpanded = true });
var loaded = store.Load();
Assert.True(firstSave.Succeeded);
Assert.True(secondSave.Succeeded);
Assert.NotNull(secondSave.SavedSettings);
Assert.Null(secondSave.WarningMessage);
Assert.True(loaded.LoadedFromDisk);
Assert.True(loaded.Settings.NavigationExpanded);
Assert.Equal(Path.GetFullPath(sceneDirectory), loaded.Settings.SceneDirectory);
Assert.Equal(Path.GetFullPath(resourceDirectory), loaded.Settings.ResourceDirectory);
Assert.Equal(
Path.GetFullPath(backgroundDirectory),
loaded.Settings.BackgroundDirectory);
Assert.Empty(Directory.EnumerateFiles(
Path.GetDirectoryName(configurationPath)!,
"*.tmp"));
using var document = JsonDocument.Parse(File.ReadAllBytes(configurationPath));
var names = document.RootElement.EnumerateObject()
.Select(property => property.Name)
.ToArray();
Assert.Equal(
[
"schemaVersion",
"sceneDirectory",
"resourceDirectory",
"backgroundDirectory",
"navigationExpanded"
],
names);
Assert.DoesNotContain(
document.RootElement.EnumerateObject(),
property => property.Name.Contains("password", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("target", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("live", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("{}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.UnsupportedSchema)]
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"unexpected\":1}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":1,\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"SchemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":\"relative\",\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidSettings)]
public void Load_StrictMalformedOrUnsafeJsonFailsClosed(
string json,
LegacyOperatorSettingsStoreFailure expectedFailure)
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
File.WriteAllText(configurationPath, json, new UTF8Encoding(false));
var store = new LegacyOperatorSettingsStore(configurationPath);
var result = store.Load();
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
Assert.False(result.LoadedFromDisk);
Assert.Equal(expectedFailure, result.Failure);
AssertPathFree(result.WarningMessage, directory.Path);
}
[Fact]
public void Load_OversizedJsonFailsBeforeParsing()
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
using (var stream = new FileStream(
configurationPath,
FileMode.CreateNew,
FileAccess.Write))
{
stream.SetLength(LegacyOperatorSettingsStore.MaximumFileBytes + 1L);
}
var result = new LegacyOperatorSettingsStore(configurationPath).Load();
Assert.Equal(LegacyOperatorSettingsStoreFailure.FileTooLarge, result.Failure);
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
AssertPathFree(result.WarningMessage, configurationPath);
}
[Fact]
public void Save_RejectsUnvalidatedPathsWithoutCreatingAConfigurationFile()
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
var store = new LegacyOperatorSettingsStore(configurationPath);
var settings = LegacyOperatorSettings.Default with
{
SceneDirectory = Path.Combine(directory.Path, "not-present-private-cuts")
};
var result = store.Save(settings);
Assert.False(result.Succeeded);
Assert.Equal(LegacyOperatorSettingsStoreFailure.InvalidSettings, result.Failure);
Assert.Null(result.SavedSettings);
Assert.False(File.Exists(configurationPath));
AssertPathFree(result.WarningMessage, settings.SceneDirectory!);
}
[Fact]
public void AbbreviateForDisplay_IsBoundedAndNeverReturnsInvalidInput()
{
using var directory = new TemporaryDirectory();
var longLeaf = new string('a', 80);
var path = directory.CreateDirectory(longLeaf);
var abbreviated = LegacyOperatorFolderValidator.AbbreviateForDisplay(path, 20);
Assert.InRange(abbreviated.Length, 1, 20);
Assert.Contains('…', abbreviated);
Assert.Equal(
LegacyOperatorFolderValidator.UnconfiguredDisplay,
LegacyOperatorFolderValidator.AbbreviateForDisplay("relative\\path"));
}
private static void WriteValidResourceFiles(string resourceDirectory)
{
var stockLabel = LegacyStockCutCatalog.Rows
.First(row => !row.IsSeparator)
.RawLabel;
WriteCp949(
Path.Combine(resourceDirectory, "종목.ini"),
["[종목]", stockLabel]);
WriteFixedFile(
resourceDirectory,
"해외.ini",
LegacyFixedMarket.Overseas);
WriteFixedFile(
resourceDirectory,
"환율.ini",
LegacyFixedMarket.Exchange);
WriteFixedFile(
resourceDirectory,
"지수.ini",
LegacyFixedMarket.Index);
WriteIndustryFile(
resourceDirectory,
"업종_코스피.ini",
IndustryMarket.Kospi);
WriteIndustryFile(
resourceDirectory,
"업종_코스닥.ini",
IndustryMarket.Kosdaq);
WriteComparisonFile(resourceDirectory);
}
private static void WriteFixedFile(
string resourceDirectory,
string fileName,
LegacyFixedMarket market)
{
var lines = new List<string>();
foreach (var section in LegacyFixedActionCatalog.Default
.GetActions(market)
.GroupBy(action => action.Section, StringComparer.Ordinal))
{
lines.Add($"[{section.Key}]");
lines.AddRange(section.Select(action => action.Label));
}
WriteCp949(Path.Combine(resourceDirectory, fileName), lines);
}
private static void WriteIndustryFile(
string resourceDirectory,
string fileName,
IndustryMarket market)
{
var actions = LegacyIndustryActionCatalog.GetActions(market);
var section = market == IndustryMarket.Kospi ? "코스피" : "코스닥";
WriteCp949(
Path.Combine(resourceDirectory, fileName),
[$"[{section}]", .. actions.Select(action => action.Label)]);
}
private static void WriteComparisonFile(string resourceDirectory)
{
var actions = LegacyComparisonWorkflowCatalog.Actions;
var directActions = actions
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
.ToArray();
var returnActions = actions
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
.ToArray();
var lines = new List<string> { "[지수]" };
lines.AddRange(LegacyComparisonWorkflowCatalog.FixedTargets
.Select(target => target.DisplayName));
lines.AddRange(directActions.Select(action => $"[{action.Label}]"));
lines.Add($"[{returnActions[0].Section}]");
lines.AddRange(returnActions.Select(action => action.Label));
WriteCp949(Path.Combine(resourceDirectory, "종목비교.ini"), lines);
}
private static void WriteCp949(string path, IEnumerable<string> lines) =>
File.WriteAllText(path, string.Join("\r\n", lines) + "\r\n", Cp949);
private static void AssertPathFree(string? warningMessage, string sensitivePath)
{
var warning = Assert.IsType<string>(warningMessage);
Assert.InRange(warning.Length, 1, 100);
Assert.DoesNotContain(sensitivePath, warning, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain('\\', warning);
Assert.DoesNotContain('/', warning);
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"MBN_STOCK_WEBVIEW_SETTINGS_{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public string CreateDirectory(string name)
{
var path = System.IO.Path.Combine(Path, name);
Directory.CreateDirectory(path);
return path;
}
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}