using System.Diagnostics; using System.IO.Compression; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Windows; using Paca.App; using Paca.Browser; using Paca.Browser.Extensions; using Paca.Browser.Tabs; using Paca.Core; using Paca.Core.Hls; using Paca.Server; using Paca.Tests.Infrastructure; using Xunit; namespace Paca.Tests.E2E; [Collection("E2E")] public sealed class ComprehensiveE2EScenarioTests : IDisposable { private readonly string _testRoot; private readonly string _dataDir; private readonly string _libDir; public ComprehensiveE2EScenarioTests() { _testRoot = Path.Combine(Path.GetTempPath(), "VD_ComprehensiveE2E_" + Guid.NewGuid().ToString("N")); _dataDir = Path.Combine(_testRoot, "data"); _libDir = Path.Combine(_testRoot, "library"); Directory.CreateDirectory(_dataDir); Directory.CreateDirectory(_libDir); Environment.SetEnvironmentVariable("VD_DATA_DIR", _dataDir); } public void Dispose() { try { if (Directory.Exists(_testRoot)) Directory.Delete(_testRoot, true); } catch { } } /// /// 시나리오 1: 크롬 확장 프로그램 대규모 병렬 설치, 악의적/손상 패키지 방어, /// 10,000회 고부하 검색 스트레스 및 수명주기 무결성 E2E /// [Fact] public async Task Scenario1_ChromeExtensions_MassiveLifecycle_And_Resilience_E2E() { var extStorage = Path.Combine(_dataDir, "extensions"); var host = new TestExtensionHost(); var manager = new ExtensionManager(extStorage, host); // 1. 20개의 가상 크롬 확장 패키지 생성 및 병렬 설치 스트레스 const int extCount = 20; var installTasks = Enumerable.Range(1, extCount).Select(async i => { var extId = $"test_extension_{i:D4}_abcdefghijklmnop"; var extName = $"Super Extension {i}"; var extVer = $"1.{i}.0"; var desc = $"Heavy load test extension number {i}"; var crxBytes = CreateSampleCrx3Bytes(extName, extVer, desc, ("manifest.json", CreateManifestJson(extName, extVer, desc)), ("content.js", Encoding.UTF8.GetBytes($"console.log('ext_{i}');")), ("icons/icon128.png", new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x00, 0x01, 0x02, 0x03 })); using var ms = new MemoryStream(crxBytes); return await manager.InstallFromPackageStreamAsync(ms, explicitId: extId); }); var installedItems = await Task.WhenAll(installTasks); Assert.Equal(extCount, installedItems.Length); Assert.Equal(extCount, manager.Extensions.Count); // 2. 활성화/비활성화 100회 토글 스트레스 var rng = new Random(42); for (int i = 0; i < 100; i++) { var target = manager.Extensions[rng.Next(manager.Extensions.Count)]; var newState = !target.IsEnabled; await manager.SetEnabledAsync(target.Id, newState); Assert.Equal(newState, target.IsEnabled); } // 3. 악의적/손상된 패키지 주입 방어 (Resilience) // 3-a) 잘못된 Magic Byte using (var badMagic = new MemoryStream(new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04 })) { await Assert.ThrowsAnyAsync(() => manager.InstallFromPackageStreamAsync(badMagic)); } // 3-b) Zip Slip 공격 경로 포함 패키지 var maliciousZip = CreateMaliciousZipSlipBytes(); using (var maliciousStream = new MemoryStream(maliciousZip)) { await Assert.ThrowsAnyAsync(() => manager.InstallFromPackageStreamAsync(maliciousStream)); } // 기존 20개 확장이 손상되지 않고 유지되는지 확인 Assert.Equal(extCount, manager.Extensions.Count); // 4. 마켓플레이스 10,000회 동시 고속 검색 및 카테고리 필터링 스트레스 var searchQueries = new[] { "adblock", "youtube", "dark", "volume", "speed", "번역", "비밀번호", "cjpalhdlnbpafiamejdnhcphjbkeiagm", "none_existing_xyz" }; var installedIds = manager.Extensions.Select(x => x.Id).ToList(); var searchTasks = Enumerable.Range(0, 10_000).Select(i => Task.Run(() => { var q = searchQueries[i % searchQueries.Length]; var results = ExtensionMarketplace.Search(q, installedIds: installedIds); Assert.NotNull(results); })); await Task.WhenAll(searchTasks); // 5. 일괄 삭제 및 스토리지 무결성 확인 foreach (var item in installedItems.ToList()) { await manager.UninstallAsync(item.Id); } Assert.Empty(manager.Extensions); var subDirs = Directory.GetDirectories(extStorage); Assert.Empty(subDirs); } /// /// 시나리오 2: HLS 다중 세그먼트 가상 스트리밍 서버 구축, 결함 주입(404, 500, Retry) 및 무결 복구 E2E /// [Fact] public async Task Scenario2_HlsDownloader_FaultInjection_And_Recovery_E2E() { // 1. 가상 HLS 미디어 HTTP 서버 구동 (HttpListener) const int segmentCount = 20; var port = GetFreePort(); using var server = new SimpleTestHlsServer(port, segmentCount); server.Start(); var playlistUrl = $"http://127.0.0.1:{port}/playlist.m3u8"; var outputDir = Path.Combine(_testRoot, "hls_output"); Directory.CreateDirectory(outputDir); var outputPath = Path.Combine(outputDir, "output_video.ts"); // 2. 다운로드 엔진 설정 (FakeMediaMuxer 주입하여 순수 파이프라인 무결성 검증) using var http = new HttpClient(); var config = new Paca.Core.Config.AppConfig { Concurrency = 4 }; var muxer = new FakeMediaMuxer(); var downloader = new HlsDownloader(http, config, muxer); var progressUpdates = new List(); var progress = new Progress(p => { progressUpdates.Add(p.Percent); }); // 3. 결함 주입 환경에서 다운로드 실행 server.InjectFaults = true; // 일부 세그먼트 첫 요청 시 500 반환 후 재시도 시 200 반환 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var outputBase = Path.Combine(outputDir, "output_video"); var resultPath = await downloader.DownloadAsync(new Uri(playlistUrl), outputBase, progress, cts.Token); // 4. 검증: 최종 파일이 온전히 생성되었고 세그먼트 데이터가 모두 병합되었는지 확인 Assert.True(File.Exists(resultPath), "최종 다운로드 파일이 존재해야 합니다."); var fileInfo = new FileInfo(resultPath); Assert.True(fileInfo.Length >= segmentCount * 1000, "전체 세그먼트 데이터가 결함 복구 후 온전히 병합되어야 합니다."); Assert.NotEmpty(progressUpdates); Assert.True(progressUpdates.Max() >= 99.0, "진행률 최대치가 100%에 도달해야 합니다."); } /// /// 시나리오 3: 원격 Kestrel 서버 기동, 30개 동시 클라이언트 병렬 페어링, /// 큐 CRUD 제어, 라이브러리 조회 및 HTTP 206 Partial Content 스트리밍 고부하 E2E /// [Fact] public async Task Scenario3_RemoteServer_HighConcurrency_MobilePairing_And_Streaming_E2E() { // 1. 가짜 미디어 라이브러리 파일 생성 (2MB) var sampleMedia = Path.Combine(_libDir, "test_movie.mp4"); var testBytes = new byte[2 * 1024 * 1024]; new Random(1234).NextBytes(testBytes); File.WriteAllBytes(sampleMedia, testBytes); var queue = new TestScenarioQueueAdapter(); const string authToken = "super-secret-e2e-token-2026"; await using var server = await ServerHost.StartAsync(new ServerOptions { Port = 0, Token = authToken, LibraryFolder = _libDir, Queue = queue }); // 2. 30개의 동시 가상 클라이언트 병렬 고부하 요청 const int clientCount = 30; var clientTasks = Enumerable.Range(1, clientCount).Select(async clientId => { using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken); // A) 큐 추가 (POST /api/queue) var addResp = await http.PostAsJsonAsync("/api/queue", new { url = $"https://youtube.com/watch?v=client_{clientId}", title = $"Video from Client {clientId}" }); Assert.True(addResp.IsSuccessStatusCode); var queueJson = await addResp.Content.ReadFromJsonAsync(); var queueId = queueJson.GetProperty("id").GetString()!; // B) 큐 일시정지 & 재개 (POST /api/queue/{id}/pause, resume) var pauseResp = await http.PostAsync($"/api/queue/{queueId}/pause", null); Assert.True(pauseResp.IsSuccessStatusCode); var resumeResp = await http.PostAsync($"/api/queue/{queueId}/resume", null); Assert.True(resumeResp.IsSuccessStatusCode); // C) 라이브러리 목록 조회 (GET /api/library) var libResp = await http.GetAsync("/api/library"); Assert.True(libResp.IsSuccessStatusCode); var libItems = await libResp.Content.ReadFromJsonAsync>(); Assert.NotNull(libItems); Assert.NotEmpty(libItems); var mediaId = libItems[0].Id; // D) HTTP 206 Partial Content 미디어 범위 스트리밍 (GET /stream/{id}) var rangeRequest = new HttpRequestMessage(HttpMethod.Get, $"/stream/{mediaId}"); rangeRequest.Headers.Range = new RangeHeaderValue(1000, 5000); // 4001 bytes var streamResp = await http.SendAsync(rangeRequest); Assert.Equal(HttpStatusCode.PartialContent, streamResp.StatusCode); var streamBytes = await streamResp.Content.ReadAsByteArrayAsync(); Assert.Equal(4001, streamBytes.Length); // 원본 바이트와 범위 데이터 일치 검증 var expectedSlice = testBytes.AsSpan(1000, 4001).ToArray(); Assert.Equal(expectedSlice, streamBytes); // E) 큐 취소 (POST /api/queue/{id}/cancel) var cancelResp = await http.PostAsync($"/api/queue/{queueId}/cancel", null); Assert.True(cancelResp.IsSuccessStatusCode); }); await Task.WhenAll(clientTasks); Assert.Equal(clientCount * 4, queue.TotalOperations); } /// /// 시나리오 4: 브라우저 대규모 탭 생성, 닫은 탭 복원(LIFO), 탭 아카이브 500개 누적 및 세션 영속 복원 E2E /// [Fact] public void Scenario4_BrowserTabSystem_MassiveTabs_Archive_And_SessionRecovery_E2E() { var tabManager = new TabManager(() => new TabItem()); // 1. 50개 탭 생성 및 속성 설정 for (int i = 1; i <= 50; i++) { var tab = tabManager.NewTab(isPrivate: i % 5 == 0); tab.Url = $"https://example.com/page/{i}"; tab.Title = $"Example Page {i}"; } Assert.Equal(50, tabManager.Tabs.Count); // 2. 20개 탭 닫기 및 닫은 탭 LIFO 복원 스택 검증 var closedUrls = new List(); for (int i = 0; i < 20; i++) { var target = tabManager.Tabs.Last(); closedUrls.Add(target.Url); tabManager.CloseTab(target); } Assert.Equal(30, tabManager.Tabs.Count); // 10개 탭 복원 for (int i = 0; i < 10; i++) { var reopened = tabManager.ReopenClosedTab(); Assert.NotNull(reopened); Assert.Equal(closedUrls[closedUrls.Count - 1 - i], reopened.Url); } Assert.Equal(40, tabManager.Tabs.Count); // 3. 세션 저장(session.json) 및 완전 복원 무결성 검증 var sessionPath = Path.Combine(_dataDir, "session.json"); var snapshot = new SessionSnapshot( tabManager.Tabs.Select(t => t.Url).ToList(), 0, tabManager.Tabs.Select(t => t.IsPinned).ToList() ); TabSessionStore.Save(sessionPath, snapshot); var restoredSnap = TabSessionStore.Load(sessionPath); Assert.NotNull(restoredSnap); Assert.Equal(tabManager.Tabs.Count, restoredSnap.Urls.Count); for (int i = 0; i < tabManager.Tabs.Count; i++) { Assert.Equal(tabManager.Tabs[i].Url, restoredSnap.Urls[i]); Assert.Equal(tabManager.Tabs[i].IsPinned, restoredSnap.Pinned[i]); } // 4. TabArchive 500개 누적 및 검색 속도/상한 검증 var archivePath = Path.Combine(_dataDir, "archive.json"); var archiveStore = new TabArchive(archivePath); for (int i = 1; i <= 600; i++) { archiveStore.Add($"Archived Item {i}", $"https://site.org/item/{i}"); } var searchHits = archiveStore.Search("Item 250"); Assert.NotEmpty(searchHits); Assert.Contains(searchHits, x => x.Title.Contains("Item 250")); } /// /// 시나리오 5: 테마 핫 스위칭, ExtensionManagerWindow UI 상태 및 마켓플레이스 연동 E2E (STA) /// [Fact] public void Scenario5_DesktopApp_ThemeSwitching_And_ExtensionUi_E2E() { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); sta.Invoke(() => { // 1. 다크 테마 ↔ 라이트 테마 10회 연속 핫 스위칭 for (int i = 0; i < 10; i++) { Paca.App.App.ApplyTheme(i % 2 == 0 ? "dark" : "light"); Assert.NotNull(Application.Current.Resources["CardBg"]); Assert.NotNull(Application.Current.Resources["TextPrimary"]); Assert.NotNull(Application.Current.Resources["AccentBrush"]); } // 2. ExtensionManagerWindow 생성 및 데이터 바인딩 무결성 검증 var host = new TestExtensionHost(); var manager = new ExtensionManager(Path.Combine(_dataDir, "ui_exts"), host); var window = new ExtensionManagerWindow(manager); Assert.NotNull(window); Assert.NotNull(window.ExtensionList); Assert.NotNull(window.StoreList); // 초기 상태: 설치된 확장 0개, EmptyState 표시 Assert.Equal(Visibility.Visible, window.EmptyState.Visibility); Assert.Contains("설치된 확장 (0)", window.TabInstalledText.Text); // 개발자 모드 토글 window.DevModeCheck.IsChecked = true; window.DevModeCheck.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ToggleButton.ClickEvent)); Assert.Equal(Visibility.Visible, window.DevModeBar.Visibility); // 스토어 탭 전환 window.TabStoreRadio.IsChecked = true; window.TabStoreRadio.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.RadioButton.CheckedEvent)); Assert.Equal(Visibility.Visible, window.StoreView.Visibility); Assert.Equal(Visibility.Collapsed, window.InstalledView.Visibility); // 스토어 검색창 입력 window.StoreSearchBox.Text = "uBlock"; var storeItems = window.StoreList.ItemsSource as IEnumerable; Assert.NotNull(storeItems); Assert.Contains(storeItems, x => x.Name.Contains("uBlock Origin")); }); } #region Helper Classes and Methods private static int GetFreePort() { var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); l.Start(); var port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } private static byte[] CreateSampleCrx3Bytes(string name, string version, string description, params (string Path, byte[] Content)[] files) { using var zipMs = new MemoryStream(); using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true)) { foreach (var (path, content) in files) { var entry = archive.CreateEntry(path); using var s = entry.Open(); s.Write(content, 0, content.Length); } } var zipBytes = zipMs.ToArray(); using var ms = new MemoryStream(); using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true); bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 }); // "Cr24" bw.Write((uint)3); // CRX3 bw.Write((uint)16); // Header length bw.Write(new byte[16]); // Header bw.Write(zipBytes); bw.Flush(); return ms.ToArray(); } private static byte[] CreateManifestJson(string name, string version, string description) { var dict = new Dictionary { ["manifest_version"] = 3, ["name"] = name, ["version"] = version, ["description"] = description }; return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dict)); } private static byte[] CreateMaliciousZipSlipBytes() { using var zipMs = new MemoryStream(); using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true)) { var attackEntry = archive.CreateEntry("../../../../../etc/passwd"); using var s = attackEntry.Open(); s.Write(Encoding.UTF8.GetBytes("attack payload")); } var zipBytes = zipMs.ToArray(); using var ms = new MemoryStream(); using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true); bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 }); bw.Write((uint)3); bw.Write((uint)8); bw.Write(new byte[8]); bw.Write(zipBytes); bw.Flush(); return ms.ToArray(); } private sealed class TestExtensionHost : IBrowserExtensionHost { public Task AddExtensionAsync(string folderPath, string extensionId) => Task.FromResult(extensionId); public Task RemoveExtensionAsync(string extensionId) => Task.FromResult(true); public Task SetExtensionEnabledAsync(string extensionId, bool enabled) => Task.FromResult(true); } private sealed class TestScenarioQueueAdapter : IQueueAdapter { private readonly List _items = new(); public int TotalOperations { get; private set; } public Task> GetItemsAsync() => Task.FromResult>(_items.ToList()); public Task AddAsync(string url, string? title) { lock (_items) { var id = Guid.NewGuid().ToString("N"); _items.Add(new QueueItemDto(id, title ?? url, url, "다운로드중", 50)); TotalOperations++; return Task.FromResult(id); } } public Task PauseAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; } public Task ResumeAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; } public Task CancelAsync(string id) { lock (_items) { _items.RemoveAll(x => x.Id == id); TotalOperations++; } return Task.CompletedTask; } public event Action? Changed { add { } remove { } } } private sealed class DirectPassthroughProcessRunner : Paca.Core.Platform.IProcessRunner { public Task RunAsync( Paca.Core.Platform.ProcessSpec spec, Action? onLine = null, CancellationToken ct = default) { return Task.FromResult(new Paca.Core.Platform.ProcessResult(0, "OK", "")); } } private sealed class FakeMediaMuxer : Paca.Core.Platform.IMediaMuxer { public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct) { File.Copy(input, output, true); return Task.CompletedTask; } public Task DownloadDashAsync( string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress? progress, CancellationToken ct) { return Task.CompletedTask; } public string? ResolveFfmpeg(Paca.Core.Config.AppConfig config) => "fake_ffmpeg.exe"; } private sealed class SimpleTestHlsServer : IDisposable { private readonly HttpListener _listener; private readonly int _segmentCount; private readonly HashSet _failedOnce = new(); private bool _disposed; public bool InjectFaults { get; set; } public SimpleTestHlsServer(int port, int segmentCount) { _segmentCount = segmentCount; _listener = new HttpListener(); _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); } public void Start() { _listener.Start(); Task.Run(ListenLoop); } private async Task ListenLoop() { while (!_disposed && _listener.IsListening) { try { var ctx = await _listener.GetContextAsync(); ProcessRequest(ctx); } catch { break; } } } private void ProcessRequest(HttpListenerContext ctx) { try { var path = ctx.Request.Url?.AbsolutePath ?? ""; if (path == "/playlist.m3u8") { var sb = new StringBuilder(); sb.AppendLine("#EXTM3U"); sb.AppendLine("#EXT-X-VERSION:3"); sb.AppendLine("#EXT-X-TARGETDURATION:2"); for (int i = 0; i < _segmentCount; i++) { sb.AppendLine("#EXTINF:2.0,"); sb.AppendLine($"segment_{i}.ts"); } sb.AppendLine("#EXT-X-ENDLIST"); var bytes = Encoding.UTF8.GetBytes(sb.ToString()); ctx.Response.ContentType = "application/vnd.apple.mpegurl"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.Close(); return; } if (path.StartsWith("/segment_") && path.EndsWith(".ts")) { var segNumStr = path.Replace("/segment_", "").Replace(".ts", ""); if (int.TryParse(segNumStr, out var segNum)) { if (InjectFaults && segNum % 4 == 0 && _failedOnce.Add(segNum)) { // 결함 주입: 500 에러 반환 후 다음 재시도에서 정상 제공 ctx.Response.StatusCode = 500; ctx.Response.Close(); return; } // 정상 세그먼트 데이터 반환 (1KB) var dummyBytes = new byte[1024]; Array.Fill(dummyBytes, (byte)(segNum & 0xFF)); ctx.Response.StatusCode = 200; ctx.Response.ContentType = "video/mp2t"; ctx.Response.ContentLength64 = dummyBytes.Length; ctx.Response.OutputStream.Write(dummyBytes, 0, dummyBytes.Length); ctx.Response.Close(); return; } } ctx.Response.StatusCode = 404; ctx.Response.Close(); } catch { } } public void Dispose() { _disposed = true; try { _listener.Stop(); _listener.Close(); } catch { } } } #endregion }