releases/tests/Paca.Tests/Core/CoreStreamingAndVaultResiliencePhase6Tests.cs

152 lines
5.9 KiB
C#

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;
/// <summary>
/// Phase 6: 코어 스트리밍 매니페스트 복원력 및 보안 볼트 키 영속화 헌법 게이트 (Gate 601 ~ 603).
/// - Gate 601: HlsDownloader 재생목록(M3U8) 매니페스트 취득 시 일시적 네트워크 장애(5xx/타임아웃) 자동 재시도
/// - Gate 602: OsProtectedCredentialVault 하위 디렉터리 자동 생성 및 마스터 키 영속화 안전성
/// - Gate 603: M3u8Parser 손상된 HTML 에러 페이지 및 빈 스트림 무결성 방어
/// </summary>
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<DownloadProgress>? 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<HttpResponseMessage> 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 = "<!DOCTYPE html><html><head><title>403 Forbidden</title></head><body><h1>Access Denied</h1></body></html>";
var htmlResult = M3u8Parser.Parse(htmlError, baseUri);
Assert.NotNull(htmlResult);
Assert.Empty(htmlResult.Variants);
Assert.Empty(htmlResult.Segments);
}
}