494 lines
19 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|