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,352 @@
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;
}
}