using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using Paca.Core.Config; using Paca.Core.Hls; using Paca.Core.Models; using Paca.Core.Platform; using Paca.Mobile.Core; using Paca.Server; using Xunit; namespace Paca.Tests.Core; public class CoreAndServerResilienceGateTests { // ========================================================================= // Gate 1: Paca.Server 로컬 API 보안 - 비웹 스킴(file, javascript 등) 주입 차단 // ========================================================================= [Theory] [InlineData("javascript:alert(1)")] [InlineData("file:///C:/Windows/System32/calc.exe")] [InlineData("data:text/html,

PWNED

")] [InlineData("gopher://evil.com/payload")] public async Task Gate_Server_Rejects_Non_Web_Schemes_In_Tab_Open(string illegalUrl) { var options = new ServerOptions { Port = 0, Token = "test-token-1234567890", Queue = new FakeQueueAdapter(), TabOpener = _ => { } }; await using var server = await ServerHost.StartAsync(options); using var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.Token); var payload = new Dictionary { ["url"] = illegalUrl }; var resp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/tabs/open", payload); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } [Theory] [InlineData("file:///C:/secrets.txt")] [InlineData("javascript:void(0)")] [InlineData("ftp://ftp.example.com/test")] public async Task Gate_Server_Rejects_Non_Web_Schemes_In_Queue(string illegalUrl) { var options = new ServerOptions { Port = 0, Token = "test-token-1234567890", Queue = new FakeQueueAdapter() }; await using var server = await ServerHost.StartAsync(options); using var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.Token); var payload = new { url = illegalUrl, title = "Illegal" }; var resp = await client.PostAsJsonAsync($"http://127.0.0.1:{server.Port}/api/queue", payload); Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); } // ========================================================================= // Gate 2: Paca.Server TabProvider 동시 컬렉션 수정 안전성 // ========================================================================= [Fact] public async Task Gate_Server_TabProvider_Thread_Safety_Against_Concurrent_Modification() { var tabList = new List { new("tab-1", "Tab 1", "https://example.com/1", false, true) }; var options = new ServerOptions { Port = 0, Token = "test-token-1234567890", Queue = new FakeQueueAdapter(), TabProvider = () => { // 열거 시점에 다른 스레드가 리스트를 변경하는 레이스 컨디션 시뮬레이션 tabList.Add(new RemoteTabInfo($"tab-{Guid.NewGuid()}", "New Tab", "https://example.com", false, false)); return tabList; } }; await using var server = await ServerHost.StartAsync(options); using var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.Token); // 동시 변경이 발생해도 500 InternalServerError (Collection was modified) 없이 200 OK 응답해야 함 var resp = await client.GetAsync($"http://127.0.0.1:{server.Port}/api/tabs"); Assert.Equal(HttpStatusCode.OK, resp.StatusCode); } // ========================================================================= // Gate 3: HlsDownloader 영구 4xx(404, 403) 오류 시 즉각 탈출 검증 (불필요한 재시도 방어) // ========================================================================= [Fact] public async Task Gate_HlsDownloader_Aborts_Immediately_On_Permanent_4xx_Error() { int requestCount = 0; var handler = new MockHttpHandler(req => { Interlocked.Increment(ref requestCount); return new HttpResponseMessage(HttpStatusCode.NotFound); }); using var http = new HttpClient(handler); var config = new AppConfig { Retries = 5 }; // 설정은 5회 재시도이지만 404면 즉시 중단해야 함 var downloader = new HlsDownloader(http, config, new FakeMediaMuxer()); var tempDir = Path.Combine(Path.GetTempPath(), "paca_test_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); try { await Assert.ThrowsAnyAsync(async () => { await downloader.DownloadAsync(new Uri("https://example.com/missing.m3u8"), Path.Combine(tempDir, "out")); }); // 404 Not Found는 5회까지 재시도하지 않고 1회만에 즉각 중단되어야 함 Assert.Equal(1, requestCount); } finally { try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } } } // ========================================================================= // Gate 4: HlsDownloader 0바이트 또는 트렁케이션 청크 감지 및 재시도 // ========================================================================= [Fact] public async Task Gate_HlsDownloader_Rejects_ZeroByte_And_Truncated_Segments() { int segmentAttempts = 0; var m3u8 = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10.0,\nseg1.ts\n#EXT-X-ENDLIST"; var handler = new MockHttpHandler(req => { if (req.RequestUri!.AbsoluteUri.EndsWith(".m3u8")) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(m3u8) }; } // seg1.ts 첫 시도는 0바이트 응답, 2번째 시도는 정상 100바이트 응답 var attempt = Interlocked.Increment(ref segmentAttempts); if (attempt == 1) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Array.Empty()) }; } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[100]) }; }); using var http = new HttpClient(handler); var config = new AppConfig { Retries = 3 }; var downloader = new HlsDownloader(http, config, new FakeMediaMuxer()); var tempDir = Path.Combine(Path.GetTempPath(), "paca_test_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); try { // 첫 시도가 0바이트이므로 실패 처리 후 2번째 시도에서 복구되어야 함 var result = await downloader.DownloadAsync(new Uri("https://example.com/test.m3u8"), Path.Combine(tempDir, "out")); Assert.True(File.Exists(result)); Assert.True(segmentAttempts >= 2, "0바이트 청크를 거르고 최소 2회 이상 시도해야 합니다."); } finally { try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } } } // ── Helper Mock ── private sealed class MockHttpHandler : HttpMessageHandler { private readonly Func _responder; public MockHttpHandler(Func responder) => _responder = responder; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(_responder(request)); } private sealed class FakeMediaMuxer : IMediaMuxer { public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct) { File.Copy(input, output, overwrite: true); return Task.CompletedTask; } public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress? progress, CancellationToken ct) => Task.CompletedTask; public string? ResolveFfmpeg(AppConfig config) => "fake-ffmpeg"; } private sealed class FakeQueueAdapter : IQueueAdapter { #pragma warning disable CS0067 public event Action? Changed; #pragma warning restore CS0067 public Task AddAsync(string url, string? title = null) => Task.FromResult(Guid.NewGuid().ToString("N")); public Task> GetItemsAsync() => Task.FromResult>(Array.Empty()); public Task PauseAsync(string id) => Task.CompletedTask; public Task ResumeAsync(string id) => Task.CompletedTask; public Task CancelAsync(string id) => Task.CompletedTask; } }