using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Threading; using System.Threading.Tasks; using Paca.Core.Config; using Paca.Core.Dash; using Paca.Core.Hls; using Paca.Core.Models; using Paca.Core.Platform; using Paca.Server; using Paca.Server.Gateway; using Xunit; namespace Paca.Tests.E2E; /// /// Phase 3 심화 하드닝 게이트 (TDD 선행 RED): /// - Gate 301: 실시간 게이트웨이 SSE 지속 스트림 및 브로드캐스트 무결성 /// - Gate 302: Passkey 챌린지 DoS 방어 메모리 상한(Bounded Capacity) /// - Gate 303: 원격 읽기 목록(Reading List) 동시 컬렉션 수정 방어 스냅샷 /// - Gate 304: DASH 다운로더 MPD 매니페스트 취득 일시 장애(503/Timeout) 재시도 /// - Gate 305: HLS 다운로더 AES-128 암호화 키 취득 일시 장애 재시도 /// public class CoreAndServerHardeningPhase3Tests : IDisposable { private readonly string _tempDir; public CoreAndServerHardeningPhase3Tests() { _tempDir = Path.Combine(Path.GetTempPath(), "paca_phase3_test_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_tempDir); } public void Dispose() { try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); } catch { } } // ========================================================================= // Gate 301: 실시간 게이트웨이 SSE 지속 스트림 및 이벤트 브로드캐스트 // ========================================================================= [Fact] public async Task Gate301_Server_SseStream_KeepsConnectionAlive_And_BroadcastsEvents() { var options = new ServerOptions { Port = 0, Token = "sse-token-test", LibraryFolder = _tempDir, Queue = new FakeQueue() }; await using var server = await ServerHost.StartAsync(options); using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") }; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "sse-token-test"); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var streamReq = new HttpRequestMessage(HttpMethod.Get, "/api/gateway/events"); var response = await client.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead, cts.Token); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType); using var streamReader = new StreamReader(await response.Content.ReadAsStreamAsync(cts.Token)); // 1) 최초 연결 이벤트 수신 확인 var firstLine = await streamReader.ReadLineAsync(cts.Token); Assert.NotNull(firstLine); Assert.StartsWith("data: ", firstLine); Assert.Contains("Gateway Connected", firstLine); // 2) 서버에서 신규 이벤트 브로드캐스트 트리거 var testEvent = new GatewayPushEvent( Guid.NewGuid().ToString("N"), GatewayEventType.DownloadStarted, "Download Started E2E", "{\"url\":\"https://example.com/test.mp4\"}", DateTimeOffset.UtcNow ); server.BroadcastGatewayEvent(testEvent); // 3) SSE 스트림으로 브로드캐스트된 이벤트가 도달하는지 수신 검증 string? receivedLine = null; while (!streamReader.EndOfStream && (receivedLine == null || !receivedLine.Contains("Download Started E2E"))) { var line = await streamReader.ReadLineAsync(cts.Token); if (line != null && line.Contains("Download Started E2E")) { receivedLine = line; break; } } Assert.NotNull(receivedLine); Assert.Contains("Download Started E2E", receivedLine); } // ========================================================================= // Gate 302: Passkey 챌린지 DoS 방어 메모리 상한(Bounded Capacity) // ========================================================================= [Fact] public async Task Gate302_Server_ActiveChallenges_BoundedCapacity_Prevents_OOM() { var options = new ServerOptions { Port = 0, Token = "passkey-token-test", LibraryFolder = _tempDir, Queue = new FakeQueue() }; await using var server = await ServerHost.StartAsync(options); using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") }; // 1,050개의 챌린지를 연속 발급 요청 for (int i = 0; i < 1050; i++) { var res = await client.GetAsync("/api/auth/passkey/challenge"); Assert.Equal(HttpStatusCode.OK, res.StatusCode); } // 서버 내부의 ActiveChallengeCount 가 최대 상한(예: 1000개) 이하로 유지되는지 검증 Assert.True(server.ActiveChallengeCount <= 1000, $"ActiveChallengeCount({server.ActiveChallengeCount})는 DoS 방어를 위해 1000개 이하로 제한되어야 합니다."); } // ========================================================================= // Gate 303: 원격 읽기 목록(Reading List) 동시 컬렉션 수정 방어 스냅샷 // ========================================================================= [Fact] public async Task Gate303_Server_ReadingList_ConcurrentModificationDefense_Audit() { var mutatingList = new List { new("1", "Item 1", "https://example.com/1", DateTime.UtcNow, false) }; var options = new ServerOptions { Port = 0, Token = "reading-token", LibraryFolder = _tempDir, Queue = new FakeQueue(), ReadingListProvider = () => mutatingList }; await using var server = await ServerHost.StartAsync(options); using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") }; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "reading-token"); // 백그라운드에서 컬렉션을 무작위로 계속 수정하면서 동시 웹 요청 수행 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var mutatorTask = Task.Run(async () => { int counter = 2; while (!cts.IsCancellationRequested) { lock (mutatingList) { mutatingList.Add(new(counter.ToString(), $"Item {counter}", $"https://example.com/{counter}", DateTime.UtcNow, false)); if (mutatingList.Count > 10) mutatingList.RemoveAt(0); } counter++; await Task.Yield(); } }); // 20개 동시 요청 var clientTasks = Enumerable.Range(0, 20).Select(async _ => { var res = await client.GetAsync("/api/reading-list"); Assert.Equal(HttpStatusCode.OK, res.StatusCode); }); await Task.WhenAll(clientTasks); cts.Cancel(); await mutatorTask; } // ========================================================================= // Gate 304: DASH 다운로더 MPD 매니페스트 취득 일시 장애(503/Timeout) 재시도 // ========================================================================= [Fact] public async Task Gate304_DashDownloader_Retries_Transient_Mpd_NetworkFailure() { int attempt = 0; var mpdXml = @" segment.mp4 "; var handler = new DelegateMockHttp(req => { if (req.RequestUri!.ToString().EndsWith(".mpd")) { attempt++; if (attempt == 1) { return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable); // 1차 503 일시 장애 } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(mpdXml, Encoding.UTF8, "application/dash+xml") }; } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[100]) }; }); using var http = new HttpClient(handler); var config = new AppConfig(); var fakeMuxer = new DashFakeMuxer(); var downloader = new DashDownloader(http, config, fakeMuxer); var outputBase = Path.Combine(_tempDir, "dash_test"); var result = await downloader.DownloadAsync(new Uri("https://paca.test/manifest.mpd"), outputBase); Assert.True(File.Exists(result)); Assert.True(attempt >= 2, "MPD 조회 시 일시 오류가 발생하면 최소 1회 이상 재시도되어야 합니다."); } // ========================================================================= // Gate 305: HLS 다운로더 AES-128 암호화 키 취득 일시 장애 재시도 // ========================================================================= [Fact] public async Task Gate305_HlsDownloader_Retries_AesKey_Transient_Errors() { int keyAttempts = 0; var m3u8 = @"#EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-KEY:METHOD=AES-128,URI=""https://paca.test/enc.key"",IV=0x00000000000000000000000000000001 #EXTINF:10.0, segment1.ts #EXT-X-ENDLIST"; var keyBytes = new byte[16]; new Random(42).NextBytes(keyBytes); var handler = new DelegateMockHttp(req => { var uri = req.RequestUri!.ToString(); if (uri.EndsWith(".m3u8")) { return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(m3u8, Encoding.UTF8, "application/vnd.apple.mpegurl") }; } if (uri.EndsWith("enc.key")) { keyAttempts++; if (keyAttempts == 1) { return new HttpResponseMessage(HttpStatusCode.InternalServerError); // 1차 500 일시 장애 } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(keyBytes) }; } // 세그먼트 (AES 암호화된 TS) var plain = new byte[160]; using var aes = System.Security.Cryptography.Aes.Create(); aes.Key = keyBytes; aes.IV = new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 }; using var enc = aes.CreateEncryptor(); var encrypted = enc.TransformFinalBlock(plain, 0, plain.Length); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(encrypted) }; }); using var http = new HttpClient(handler); var config = new AppConfig(); var fakeMuxer = new HlsFakeMuxer(); var downloader = new HlsDownloader(http, config, fakeMuxer); var outputBase = Path.Combine(_tempDir, "hls_aes_test"); var result = await downloader.DownloadAsync(new Uri("https://paca.test/video.m3u8"), outputBase); Assert.True(File.Exists(result)); Assert.True(keyAttempts >= 2, "AES 키 취득 실패 시 재시도를 수행하여 복호화를 완수해야 합니다."); } private sealed class DelegateMockHttp : HttpMessageHandler { private readonly Func _handler; public DelegateMockHttp(Func handler) => _handler = handler; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { return Task.FromResult(_handler(request)); } } private sealed class FakeQueue : IQueueAdapter { public event Action? Changed { add { } remove { } } public Task> GetItemsAsync() => Task.FromResult>(Array.Empty()); public Task AddAsync(string url, string? title = null) => Task.FromResult(Guid.NewGuid().ToString()); public Task PauseAsync(string id) => Task.CompletedTask; public Task ResumeAsync(string id) => Task.CompletedTask; public Task CancelAsync(string id) => Task.CompletedTask; } private sealed class DashFakeMuxer : IMediaMuxer { public string? ResolveFfmpeg(AppConfig config) => "fake_ffmpeg"; public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress? progress = null, CancellationToken ct = default) { File.WriteAllBytes(output, new byte[200]); return Task.CompletedTask; } public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct = default) => Task.CompletedTask; public Task MuxVideoAudioAsync(string ffmpeg, string video, string audio, string output, CancellationToken ct = default) => Task.CompletedTask; public Task IsValidMediaAsync(string ffprobe, string file) => Task.FromResult(true); public Task ConcatAsync(string ffmpeg, IReadOnlyList inputs, string output, CancellationToken ct = default) => Task.CompletedTask; public Task ExtractAudioAsync(string ffmpeg, string input, string output, string format = "mp3", CancellationToken ct = default) => Task.CompletedTask; } private sealed class HlsFakeMuxer : IMediaMuxer { public string? ResolveFfmpeg(AppConfig config) => "fake_ffmpeg"; public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct = default) { File.Copy(input, output, true); return Task.CompletedTask; } public Task MuxVideoAudioAsync(string ffmpeg, string video, string audio, string output, CancellationToken ct = default) => Task.CompletedTask; public Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec, HttpClient http, IProgress? progress = null, CancellationToken ct = default) => Task.CompletedTask; public Task IsValidMediaAsync(string ffprobe, string file) => Task.FromResult(true); public Task ConcatAsync(string ffmpeg, IReadOnlyList inputs, string output, CancellationToken ct = default) => Task.CompletedTask; public Task ExtractAudioAsync(string ffmpeg, string input, string output, string format = "mp3", CancellationToken ct = default) => Task.CompletedTask; } }