using System.Text; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MBN_STOCK_WEBVIEW.Infrastructure; namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests; public sealed class S5025TrustedManualFileDataSourceTests { private static readonly Encoding Cp949 = CreateCp949Encoding(); [Theory] [InlineData(S5025ManualAudience.Individual, "개인.dat")] [InlineData(S5025ManualAudience.Foreign, "외국인.dat")] [InlineData(S5025ManualAudience.Institution, "기관.dat")] public async Task ReadAsync_maps_closed_audience_to_fixed_cp949_file( S5025ManualAudience audience, string expectedFileName) { using var directory = new TemporaryDirectory(); WriteValidFile(Path.Combine(directory.Path, expectedFileName)); var source = new S5025TrustedManualFileDataSource(directory.Path); var rows = await source.ReadAsync(audience); Assert.Equal(5, rows.Count); Assert.Equal("삼성전자", rows[0].LeftName); Assert.Equal("1,234", rows[0].LeftAmount); Assert.Equal("SK하이닉스", rows[0].RightName); Assert.Equal("-987", rows[0].RightAmount); } [Fact] public async Task CreateFromEnvironment_uses_only_documented_directory_setting() { using var directory = new TemporaryDirectory(); WriteValidFile(Path.Combine(directory.Path, "개인.dat")); var requestedNames = new List(); var source = S5025TrustedManualFileDataSource.CreateFromEnvironment(name => { requestedNames.Add(name); return directory.Path; }); var rows = await source.ReadAsync(S5025ManualAudience.Individual); Assert.Equal( S5025TrustedManualFileDataSource.DirectoryEnvironmentVariable, Assert.Single(requestedNames)); Assert.Equal(5, rows.Count); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("relative-data")] public void Constructor_rejects_missing_or_non_absolute_directory(string? path) { Assert.Throws(() => new S5025TrustedManualFileDataSource(path!)); } [Fact] public void Constructor_rejects_nonexistent_absolute_directory() { var missing = Path.Combine( Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing"); Assert.Throws(() => new S5025TrustedManualFileDataSource(missing)); } [Theory] [InlineData(S5025ManualAudience.Individual)] [InlineData(S5025ManualAudience.Foreign)] [InlineData(S5025ManualAudience.Institution)] public async Task ReadAsync_does_not_fall_back_when_fixed_file_is_missing( S5025ManualAudience audience) { using var directory = new TemporaryDirectory(); var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => source.ReadAsync(audience)); } [Fact] public async Task ReadAsync_rejects_unknown_audience_before_any_file_lookup() { using var directory = new TemporaryDirectory(); var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => source.ReadAsync((S5025ManualAudience)99)); } [Theory] [InlineData( "L1^1^R1^2\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2", "four rows")] [InlineData( "L1^1^R1^2\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2\r\nL6^1^R6^2", "six rows")] [InlineData( "L1^1^R1\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2", "three columns")] [InlineData( "L1^1^R1^2^EXTRA\r\nL2^1^R2^2\r\nL3^1^R3^2\r\nL4^1^R4^2\r\nL5^1^R5^2", "five columns")] public async Task ReadAsync_requires_exactly_five_rows_and_four_columns( string content, string _) { using var directory = new TemporaryDirectory(); await File.WriteAllBytesAsync( Path.Combine(directory.Path, "개인.dat"), Cp949.GetBytes(content + "\r\n\r\n")); var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => source.ReadAsync(S5025ManualAudience.Individual)); } [Fact] public async Task ReadAsync_rejects_missing_empty_terminal_record() { using var directory = new TemporaryDirectory(); await File.WriteAllBytesAsync( Path.Combine(directory.Path, "개인.dat"), Cp949.GetBytes(ValidContent() + "\r\n")); var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => source.ReadAsync(S5025ManualAudience.Individual)); } [Fact] public async Task ReadAsync_accepts_the_original_single_empty_terminal_record() { using var directory = new TemporaryDirectory(); var content = ValidContent() + "\r\n\r\n"; await File.WriteAllBytesAsync( Path.Combine(directory.Path, "개인.dat"), Cp949.GetBytes(content)); var source = new S5025TrustedManualFileDataSource(directory.Path); var rows = await source.ReadAsync(S5025ManualAudience.Individual); Assert.Equal(5, rows.Count); } [Fact] public async Task ReadAsync_rejects_multiple_empty_terminal_records() { using var directory = new TemporaryDirectory(); var content = ValidContent() + "\r\n\r\n\r\n"; await File.WriteAllBytesAsync( Path.Combine(directory.Path, "개인.dat"), Cp949.GetBytes(content)); var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => source.ReadAsync(S5025ManualAudience.Individual)); } [Fact] public async Task ReadAsync_rejects_invalid_cp949_and_unicode_bom_files() { using var invalidDirectory = new TemporaryDirectory(); await File.WriteAllBytesAsync( Path.Combine(invalidDirectory.Path, "개인.dat"), [0x81]); var invalidSource = new S5025TrustedManualFileDataSource(invalidDirectory.Path); await Assert.ThrowsAsync(() => invalidSource.ReadAsync(S5025ManualAudience.Individual)); using var bomDirectory = new TemporaryDirectory(); var content = ValidContent(); await File.WriteAllBytesAsync( Path.Combine(bomDirectory.Path, "개인.dat"), Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(content)).ToArray()); var bomSource = new S5025TrustedManualFileDataSource(bomDirectory.Path); await Assert.ThrowsAsync(() => bomSource.ReadAsync(S5025ManualAudience.Individual)); } [Fact] public async Task ReadAsync_rejects_oversized_file_before_decoding() { using var directory = new TemporaryDirectory(); await File.WriteAllBytesAsync( Path.Combine(directory.Path, "개인.dat"), new byte[S5025TrustedManualFileDataSource.MaximumFileSizeBytes + 1]); var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => source.ReadAsync(S5025ManualAudience.Individual)); } [Fact] public async Task ReadAsync_rejects_reparse_file_in_trusted_directory() { using var directory = new TemporaryDirectory(); using var outside = new TemporaryDirectory(); var target = Path.Combine(outside.Path, "outside.dat"); WriteValidFile(target); var link = Path.Combine(directory.Path, "개인.dat"); if (!TryCreateFileSymbolicLink(link, target)) { return; } var source = new S5025TrustedManualFileDataSource(directory.Path); await Assert.ThrowsAsync(() => source.ReadAsync(S5025ManualAudience.Individual)); } [Fact] public void Constructor_rejects_reparse_directory_or_ancestor() { using var parent = new TemporaryDirectory(); using var outside = new TemporaryDirectory(); var link = Path.Combine(parent.Path, "linked-data"); if (!TryCreateDirectorySymbolicLink(link, outside.Path)) { return; } Assert.Throws(() => new S5025TrustedManualFileDataSource(link)); } [Fact] public async Task ReadAsync_honors_pre_cancelled_token() { using var directory = new TemporaryDirectory(); WriteValidFile(Path.Combine(directory.Path, "개인.dat")); var source = new S5025TrustedManualFileDataSource(directory.Path); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => source.ReadAsync(S5025ManualAudience.Individual, cancellation.Token)); } private static void WriteValidFile(string path) { var content = ValidContent() + "\r\n\r\n"; File.WriteAllBytes(path, Cp949.GetBytes(content)); } private static string ValidContent() => string.Join( "\r\n", "삼성전자^1,234^SK하이닉스^-987", "현대차^2^기아^-2", "NAVER^3^카카오^-3", "LG화학^4^셀트리온^-4", "POSCO홀딩스^5^한화에어로스페이스^-5"); private static Encoding CreateCp949Encoding() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); return Encoding.GetEncoding( 949, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); } private static bool TryCreateFileSymbolicLink(string link, string target) { try { File.CreateSymbolicLink(link, target); return true; } catch (Exception exception) when (exception is UnauthorizedAccessException or IOException or PlatformNotSupportedException) { return false; } } private static bool TryCreateDirectorySymbolicLink(string link, string target) { try { Directory.CreateSymbolicLink(link, target); return true; } catch (Exception exception) when (exception is UnauthorizedAccessException or IOException or PlatformNotSupportedException) { return false; } } private sealed class TemporaryDirectory : IDisposable { public TemporaryDirectory() { Path = System.IO.Path.Combine( System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path); } public string Path { get; } public void Dispose() { try { Directory.Delete(Path, recursive: true); } catch (IOException) { // A failed cleanup must not hide the behavior under test. } catch (UnauthorizedAccessException) { // A failed cleanup must not hide the behavior under test. } } } }