using System; using System.IO; using System.Net; using System.Net.Http; using System.Text; 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.Core.Security; using Xunit; namespace Paca.Tests.Core; /// /// Phase 6: 코어 스트리밍 매니페스트 복원력 및 보안 볼트 키 영속화 헌법 게이트 (Gate 601 ~ 603). /// - Gate 601: HlsDownloader 재생목록(M3U8) 매니페스트 취득 시 일시적 네트워크 장애(5xx/타임아웃) 자동 재시도 /// - Gate 602: OsProtectedCredentialVault 하위 디렉터리 자동 생성 및 마스터 키 영속화 안전성 /// - Gate 603: M3u8Parser 손상된 HTML 에러 페이지 및 빈 스트림 무결성 방어 /// public class CoreStreamingAndVaultResiliencePhase6Tests { private sealed class StubMuxer : 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) => "mock_ffmpeg"; } private sealed class RetryCountingHandler : HttpMessageHandler { public int PlaylistRequests = 0; private readonly string _validM3u8; public RetryCountingHandler(string validM3u8) { _validM3u8 = validM3u8; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var path = request.RequestUri?.AbsolutePath ?? ""; if (path.EndsWith(".m3u8")) { PlaylistRequests++; if (PlaylistRequests == 1) { // 1회차 요청은 503 일시 장애 시뮬레이션 return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); } return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(_validM3u8, Encoding.UTF8, "application/vnd.apple.mpegurl") }); } if (path.EndsWith(".ts")) { return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(new byte[128]) }); } return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); } } [Fact] public async Task Gate601_HlsDownloader_Retries_Playlist_Manifest_On_Transient_Faults_Audit() { var validM3u8 = "#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXTINF:2.0,\nseg0.ts\n#EXT-X-ENDLIST\n"; var handler = new RetryCountingHandler(validM3u8); using var http = new HttpClient(handler); var config = new AppConfig { Retries = 3 }; var downloader = new HlsDownloader(http, config, new StubMuxer()); var tempDir = Path.Combine(Path.GetTempPath(), "paca_gate601_" + Guid.NewGuid().ToString("N")); try { Directory.CreateDirectory(tempDir); var outputBase = Path.Combine(tempDir, "video"); var result = await downloader.DownloadAsync(new Uri("http://mock-server.local/stream.m3u8"), outputBase); Assert.True(File.Exists(result)); Assert.True(handler.PlaylistRequests >= 2, $"Expected at least 2 requests (1 failure + 1 retry), actual: {handler.PlaylistRequests}"); } finally { try { Directory.Delete(tempDir, true); } catch { } } } [Fact] public async Task Gate602_OsProtectedCredentialVault_Ensures_Directory_Created_For_KeyFile_Audit() { var tempDir = Path.Combine(Path.GetTempPath(), "paca_gate602_" + Guid.NewGuid().ToString("N")); var nestedDir = Path.Combine(tempDir, "deeply", "nested", "secure", "vaults"); var vaultPath = Path.Combine(nestedDir, "test.pacavault"); var keyPath = vaultPath + ".key"; try { Assert.False(Directory.Exists(nestedDir)); var mockAuth = new MockBiometricAuthenticator(); using (var vault = new OsProtectedCredentialVault(vaultPath, mockAuth)) { var unlockResult = await vault.UnlockWithBiometricAsync(); Assert.True(unlockResult.IsSuccess); // 디렉터리와 키 파일이 정상적으로 생성되었는지 검증 Assert.True(Directory.Exists(nestedDir), "Vault directory should be auto-created"); Assert.True(File.Exists(keyPath), "Master key file should be created"); Assert.True(new FileInfo(keyPath).Length > 0, "Key file should not be empty"); } } finally { try { Directory.Delete(tempDir, true); } catch { } } } [Fact] public void Gate603_M3u8Parser_Corrupted_And_Empty_Manifest_Safety_Audit() { var baseUri = new Uri("http://example.com/live/playlist.m3u8"); // 1. 빈 문자열 및 공백 var emptyResult = M3u8Parser.Parse("", baseUri); Assert.NotNull(emptyResult); Assert.Equal(PlaylistType.Media, emptyResult.Type); Assert.Empty(emptyResult.Variants); Assert.Empty(emptyResult.Segments); // 2. HTML 403 오류 응답 페이지 주입 var htmlError = "403 Forbidden

Access Denied

"; var htmlResult = M3u8Parser.Parse(htmlError, baseUri); Assert.NotNull(htmlResult); Assert.Empty(htmlResult.Variants); Assert.Empty(htmlResult.Segments); } }