352 lines
15 KiB
C#
352 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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 암호화 키 취득 일시 장애 재시도
|
|
/// </summary>
|
|
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<Paca.Mobile.Core.ReadingListItemDto>
|
|
{
|
|
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 = @"<MPD xmlns=""urn:mpeg:dash:schema:mpd:2011"" mediaPresentationDuration=""PT5S"">
|
|
<Period>
|
|
<AdaptationSet mimeType=""video/mp4"">
|
|
<Representation id=""v1"" bandwidth=""1000000"">
|
|
<BaseURL>segment.mp4</BaseURL>
|
|
</Representation>
|
|
</AdaptationSet>
|
|
</Period>
|
|
</MPD>";
|
|
|
|
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<HttpRequestMessage, HttpResponseMessage> _handler;
|
|
public DelegateMockHttp(Func<HttpRequestMessage, HttpResponseMessage> handler) => _handler = handler;
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
return Task.FromResult(_handler(request));
|
|
}
|
|
}
|
|
|
|
private sealed class FakeQueue : IQueueAdapter
|
|
{
|
|
public event Action? Changed { add { } remove { } }
|
|
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Array.Empty<QueueItemDto>());
|
|
public Task<string> 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<DownloadProgress>? 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<bool> IsValidMediaAsync(string ffprobe, string file) => Task.FromResult(true);
|
|
public Task ConcatAsync(string ffmpeg, IReadOnlyList<string> 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<DownloadProgress>? progress = null, CancellationToken ct = default) => Task.CompletedTask;
|
|
public Task<bool> IsValidMediaAsync(string ffprobe, string file) => Task.FromResult(true);
|
|
public Task ConcatAsync(string ffmpeg, IReadOnlyList<string> 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;
|
|
}
|
|
}
|