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:
Yun Chan 2026-09-14 00:39:44 +09:00
parent a88aafa1e5
commit 0e568f1f0a
975 changed files with 130593 additions and 16783 deletions

View 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;
}
}