feat(ads/seo/release): v1.2.0 with live Google Ads, isolated ad profile, ads.txt, and SEO/AEO/GEO optimization
This commit is contained in:
parent
a88aafa1e5
commit
0e568f1f0a
975 changed files with 130593 additions and 16783 deletions
224
tests/Paca.Tests/Core/CoreAndServerResilienceGateTests.cs
Normal file
224
tests/Paca.Tests/Core/CoreAndServerResilienceGateTests.cs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue