224 lines
9.3 KiB
C#
224 lines
9.3 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.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,<h1>PWNED</h1>")]
|
|
[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<string, string> { ["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<RemoteTabInfo>
|
|
{
|
|
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<Exception>(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<byte>())
|
|
};
|
|
}
|
|
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<HttpRequestMessage, HttpResponseMessage> _responder;
|
|
public MockHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
|
|
protected override Task<HttpResponseMessage> 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<DownloadProgress>? 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<string> AddAsync(string url, string? title = null) => Task.FromResult(Guid.NewGuid().ToString("N"));
|
|
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Array.Empty<QueueItemDto>());
|
|
public Task PauseAsync(string id) => Task.CompletedTask;
|
|
public Task ResumeAsync(string id) => Task.CompletedTask;
|
|
public Task CancelAsync(string id) => Task.CompletedTask;
|
|
}
|
|
}
|