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,606 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Windows;
using Paca.App;
using Paca.Browser;
using Paca.Browser.Extensions;
using Paca.Browser.Tabs;
using Paca.Core;
using Paca.Core.Hls;
using Paca.Server;
using Paca.Tests.Infrastructure;
using Xunit;
namespace Paca.Tests.E2E;
[Collection("E2E")]
public sealed class ComprehensiveE2EScenarioTests : IDisposable
{
private readonly string _testRoot;
private readonly string _dataDir;
private readonly string _libDir;
public ComprehensiveE2EScenarioTests()
{
_testRoot = Path.Combine(Path.GetTempPath(), "VD_ComprehensiveE2E_" + Guid.NewGuid().ToString("N"));
_dataDir = Path.Combine(_testRoot, "data");
_libDir = Path.Combine(_testRoot, "library");
Directory.CreateDirectory(_dataDir);
Directory.CreateDirectory(_libDir);
Environment.SetEnvironmentVariable("VD_DATA_DIR", _dataDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_testRoot))
Directory.Delete(_testRoot, true);
}
catch { }
}
/// <summary>
/// 시나리오 1: 크롬 확장 프로그램 대규모 병렬 설치, 악의적/손상 패키지 방어,
/// 10,000회 고부하 검색 스트레스 및 수명주기 무결성 E2E
/// </summary>
[Fact]
public async Task Scenario1_ChromeExtensions_MassiveLifecycle_And_Resilience_E2E()
{
var extStorage = Path.Combine(_dataDir, "extensions");
var host = new TestExtensionHost();
var manager = new ExtensionManager(extStorage, host);
// 1. 20개의 가상 크롬 확장 패키지 생성 및 병렬 설치 스트레스
const int extCount = 20;
var installTasks = Enumerable.Range(1, extCount).Select(async i =>
{
var extId = $"test_extension_{i:D4}_abcdefghijklmnop";
var extName = $"Super Extension {i}";
var extVer = $"1.{i}.0";
var desc = $"Heavy load test extension number {i}";
var crxBytes = CreateSampleCrx3Bytes(extName, extVer, desc,
("manifest.json", CreateManifestJson(extName, extVer, desc)),
("content.js", Encoding.UTF8.GetBytes($"console.log('ext_{i}');")),
("icons/icon128.png", new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x00, 0x01, 0x02, 0x03 }));
using var ms = new MemoryStream(crxBytes);
return await manager.InstallFromPackageStreamAsync(ms, explicitId: extId);
});
var installedItems = await Task.WhenAll(installTasks);
Assert.Equal(extCount, installedItems.Length);
Assert.Equal(extCount, manager.Extensions.Count);
// 2. 활성화/비활성화 100회 토글 스트레스
var rng = new Random(42);
for (int i = 0; i < 100; i++)
{
var target = manager.Extensions[rng.Next(manager.Extensions.Count)];
var newState = !target.IsEnabled;
await manager.SetEnabledAsync(target.Id, newState);
Assert.Equal(newState, target.IsEnabled);
}
// 3. 악의적/손상된 패키지 주입 방어 (Resilience)
// 3-a) 잘못된 Magic Byte
using (var badMagic = new MemoryStream(new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04 }))
{
await Assert.ThrowsAnyAsync<Exception>(() => manager.InstallFromPackageStreamAsync(badMagic));
}
// 3-b) Zip Slip 공격 경로 포함 패키지
var maliciousZip = CreateMaliciousZipSlipBytes();
using (var maliciousStream = new MemoryStream(maliciousZip))
{
await Assert.ThrowsAnyAsync<Exception>(() => manager.InstallFromPackageStreamAsync(maliciousStream));
}
// 기존 20개 확장이 손상되지 않고 유지되는지 확인
Assert.Equal(extCount, manager.Extensions.Count);
// 4. 마켓플레이스 10,000회 동시 고속 검색 및 카테고리 필터링 스트레스
var searchQueries = new[] { "adblock", "youtube", "dark", "volume", "speed", "번역", "비밀번호", "cjpalhdlnbpafiamejdnhcphjbkeiagm", "none_existing_xyz" };
var installedIds = manager.Extensions.Select(x => x.Id).ToList();
var searchTasks = Enumerable.Range(0, 10_000).Select(i => Task.Run(() =>
{
var q = searchQueries[i % searchQueries.Length];
var results = ExtensionMarketplace.Search(q, installedIds: installedIds);
Assert.NotNull(results);
}));
await Task.WhenAll(searchTasks);
// 5. 일괄 삭제 및 스토리지 무결성 확인
foreach (var item in installedItems.ToList())
{
await manager.UninstallAsync(item.Id);
}
Assert.Empty(manager.Extensions);
var subDirs = Directory.GetDirectories(extStorage);
Assert.Empty(subDirs);
}
/// <summary>
/// 시나리오 2: HLS 다중 세그먼트 가상 스트리밍 서버 구축, 결함 주입(404, 500, Retry) 및 무결 복구 E2E
/// </summary>
[Fact]
public async Task Scenario2_HlsDownloader_FaultInjection_And_Recovery_E2E()
{
// 1. 가상 HLS 미디어 HTTP 서버 구동 (HttpListener)
const int segmentCount = 20;
var port = GetFreePort();
using var server = new SimpleTestHlsServer(port, segmentCount);
server.Start();
var playlistUrl = $"http://127.0.0.1:{port}/playlist.m3u8";
var outputDir = Path.Combine(_testRoot, "hls_output");
Directory.CreateDirectory(outputDir);
var outputPath = Path.Combine(outputDir, "output_video.ts");
// 2. 다운로드 엔진 설정 (FakeMediaMuxer 주입하여 순수 파이프라인 무결성 검증)
using var http = new HttpClient();
var config = new Paca.Core.Config.AppConfig { Concurrency = 4 };
var muxer = new FakeMediaMuxer();
var downloader = new HlsDownloader(http, config, muxer);
var progressUpdates = new List<double>();
var progress = new Progress<Paca.Core.Models.DownloadProgress>(p =>
{
progressUpdates.Add(p.Percent);
});
// 3. 결함 주입 환경에서 다운로드 실행
server.InjectFaults = true; // 일부 세그먼트 첫 요청 시 500 반환 후 재시도 시 200 반환
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var outputBase = Path.Combine(outputDir, "output_video");
var resultPath = await downloader.DownloadAsync(new Uri(playlistUrl), outputBase, progress, cts.Token);
// 4. 검증: 최종 파일이 온전히 생성되었고 세그먼트 데이터가 모두 병합되었는지 확인
Assert.True(File.Exists(resultPath), "최종 다운로드 파일이 존재해야 합니다.");
var fileInfo = new FileInfo(resultPath);
Assert.True(fileInfo.Length >= segmentCount * 1000, "전체 세그먼트 데이터가 결함 복구 후 온전히 병합되어야 합니다.");
Assert.NotEmpty(progressUpdates);
Assert.True(progressUpdates.Max() >= 99.0, "진행률 최대치가 100%에 도달해야 합니다.");
}
/// <summary>
/// 시나리오 3: 원격 Kestrel 서버 기동, 30개 동시 클라이언트 병렬 페어링,
/// 큐 CRUD 제어, 라이브러리 조회 및 HTTP 206 Partial Content 스트리밍 고부하 E2E
/// </summary>
[Fact]
public async Task Scenario3_RemoteServer_HighConcurrency_MobilePairing_And_Streaming_E2E()
{
// 1. 가짜 미디어 라이브러리 파일 생성 (2MB)
var sampleMedia = Path.Combine(_libDir, "test_movie.mp4");
var testBytes = new byte[2 * 1024 * 1024];
new Random(1234).NextBytes(testBytes);
File.WriteAllBytes(sampleMedia, testBytes);
var queue = new TestScenarioQueueAdapter();
const string authToken = "super-secret-e2e-token-2026";
await using var server = await ServerHost.StartAsync(new ServerOptions
{
Port = 0,
Token = authToken,
LibraryFolder = _libDir,
Queue = queue
});
// 2. 30개의 동시 가상 클라이언트 병렬 고부하 요청
const int clientCount = 30;
var clientTasks = Enumerable.Range(1, clientCount).Select(async clientId =>
{
using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
// A) 큐 추가 (POST /api/queue)
var addResp = await http.PostAsJsonAsync("/api/queue", new { url = $"https://youtube.com/watch?v=client_{clientId}", title = $"Video from Client {clientId}" });
Assert.True(addResp.IsSuccessStatusCode);
var queueJson = await addResp.Content.ReadFromJsonAsync<JsonElement>();
var queueId = queueJson.GetProperty("id").GetString()!;
// B) 큐 일시정지 & 재개 (POST /api/queue/{id}/pause, resume)
var pauseResp = await http.PostAsync($"/api/queue/{queueId}/pause", null);
Assert.True(pauseResp.IsSuccessStatusCode);
var resumeResp = await http.PostAsync($"/api/queue/{queueId}/resume", null);
Assert.True(resumeResp.IsSuccessStatusCode);
// C) 라이브러리 목록 조회 (GET /api/library)
var libResp = await http.GetAsync("/api/library");
Assert.True(libResp.IsSuccessStatusCode);
var libItems = await libResp.Content.ReadFromJsonAsync<List<LibraryItemDto>>();
Assert.NotNull(libItems);
Assert.NotEmpty(libItems);
var mediaId = libItems[0].Id;
// D) HTTP 206 Partial Content 미디어 범위 스트리밍 (GET /stream/{id})
var rangeRequest = new HttpRequestMessage(HttpMethod.Get, $"/stream/{mediaId}");
rangeRequest.Headers.Range = new RangeHeaderValue(1000, 5000); // 4001 bytes
var streamResp = await http.SendAsync(rangeRequest);
Assert.Equal(HttpStatusCode.PartialContent, streamResp.StatusCode);
var streamBytes = await streamResp.Content.ReadAsByteArrayAsync();
Assert.Equal(4001, streamBytes.Length);
// 원본 바이트와 범위 데이터 일치 검증
var expectedSlice = testBytes.AsSpan(1000, 4001).ToArray();
Assert.Equal(expectedSlice, streamBytes);
// E) 큐 취소 (POST /api/queue/{id}/cancel)
var cancelResp = await http.PostAsync($"/api/queue/{queueId}/cancel", null);
Assert.True(cancelResp.IsSuccessStatusCode);
});
await Task.WhenAll(clientTasks);
Assert.Equal(clientCount * 4, queue.TotalOperations);
}
/// <summary>
/// 시나리오 4: 브라우저 대규모 탭 생성, 닫은 탭 복원(LIFO), 탭 아카이브 500개 누적 및 세션 영속 복원 E2E
/// </summary>
[Fact]
public void Scenario4_BrowserTabSystem_MassiveTabs_Archive_And_SessionRecovery_E2E()
{
var tabManager = new TabManager(() => new TabItem());
// 1. 50개 탭 생성 및 속성 설정
for (int i = 1; i <= 50; i++)
{
var tab = tabManager.NewTab(isPrivate: i % 5 == 0);
tab.Url = $"https://example.com/page/{i}";
tab.Title = $"Example Page {i}";
}
Assert.Equal(50, tabManager.Tabs.Count);
// 2. 20개 탭 닫기 및 닫은 탭 LIFO 복원 스택 검증
var closedUrls = new List<string>();
for (int i = 0; i < 20; i++)
{
var target = tabManager.Tabs.Last();
closedUrls.Add(target.Url);
tabManager.CloseTab(target);
}
Assert.Equal(30, tabManager.Tabs.Count);
// 10개 탭 복원
for (int i = 0; i < 10; i++)
{
var reopened = tabManager.ReopenClosedTab();
Assert.NotNull(reopened);
Assert.Equal(closedUrls[closedUrls.Count - 1 - i], reopened.Url);
}
Assert.Equal(40, tabManager.Tabs.Count);
// 3. 세션 저장(session.json) 및 완전 복원 무결성 검증
var sessionPath = Path.Combine(_dataDir, "session.json");
var snapshot = new SessionSnapshot(
tabManager.Tabs.Select(t => t.Url).ToList(),
0,
tabManager.Tabs.Select(t => t.IsPinned).ToList()
);
TabSessionStore.Save(sessionPath, snapshot);
var restoredSnap = TabSessionStore.Load(sessionPath);
Assert.NotNull(restoredSnap);
Assert.Equal(tabManager.Tabs.Count, restoredSnap.Urls.Count);
for (int i = 0; i < tabManager.Tabs.Count; i++)
{
Assert.Equal(tabManager.Tabs[i].Url, restoredSnap.Urls[i]);
Assert.Equal(tabManager.Tabs[i].IsPinned, restoredSnap.Pinned[i]);
}
// 4. TabArchive 500개 누적 및 검색 속도/상한 검증
var archivePath = Path.Combine(_dataDir, "archive.json");
var archiveStore = new TabArchive(archivePath);
for (int i = 1; i <= 600; i++)
{
archiveStore.Add($"Archived Item {i}", $"https://site.org/item/{i}");
}
var searchHits = archiveStore.Search("Item 250");
Assert.NotEmpty(searchHits);
Assert.Contains(searchHits, x => x.Title.Contains("Item 250"));
}
/// <summary>
/// 시나리오 5: 테마 핫 스위칭, ExtensionManagerWindow UI 상태 및 마켓플레이스 연동 E2E (STA)
/// </summary>
[Fact]
public void Scenario5_DesktopApp_ThemeSwitching_And_ExtensionUi_E2E()
{
using var sta = StaDispatcher.Start();
WpfTestApp.Ensure(sta.Dispatcher);
sta.Invoke(() =>
{
// 1. 다크 테마 ↔ 라이트 테마 10회 연속 핫 스위칭
for (int i = 0; i < 10; i++)
{
Paca.App.App.ApplyTheme(i % 2 == 0 ? "dark" : "light");
Assert.NotNull(Application.Current.Resources["CardBg"]);
Assert.NotNull(Application.Current.Resources["TextPrimary"]);
Assert.NotNull(Application.Current.Resources["AccentBrush"]);
}
// 2. ExtensionManagerWindow 생성 및 데이터 바인딩 무결성 검증
var host = new TestExtensionHost();
var manager = new ExtensionManager(Path.Combine(_dataDir, "ui_exts"), host);
var window = new ExtensionManagerWindow(manager);
Assert.NotNull(window);
Assert.NotNull(window.ExtensionList);
Assert.NotNull(window.StoreList);
// 초기 상태: 설치된 확장 0개, EmptyState 표시
Assert.Equal(Visibility.Visible, window.EmptyState.Visibility);
Assert.Contains("설치된 확장 (0)", window.TabInstalledText.Text);
// 개발자 모드 토글
window.DevModeCheck.IsChecked = true;
window.DevModeCheck.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ToggleButton.ClickEvent));
Assert.Equal(Visibility.Visible, window.DevModeBar.Visibility);
// 스토어 탭 전환
window.TabStoreRadio.IsChecked = true;
window.TabStoreRadio.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.RadioButton.CheckedEvent));
Assert.Equal(Visibility.Visible, window.StoreView.Visibility);
Assert.Equal(Visibility.Collapsed, window.InstalledView.Visibility);
// 스토어 검색창 입력
window.StoreSearchBox.Text = "uBlock";
var storeItems = window.StoreList.ItemsSource as IEnumerable<MarketplaceItem>;
Assert.NotNull(storeItems);
Assert.Contains(storeItems, x => x.Name.Contains("uBlock Origin"));
});
}
#region Helper Classes and Methods
private static int GetFreePort()
{
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private static byte[] CreateSampleCrx3Bytes(string name, string version, string description, params (string Path, byte[] Content)[] files)
{
using var zipMs = new MemoryStream();
using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (path, content) in files)
{
var entry = archive.CreateEntry(path);
using var s = entry.Open();
s.Write(content, 0, content.Length);
}
}
var zipBytes = zipMs.ToArray();
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 }); // "Cr24"
bw.Write((uint)3); // CRX3
bw.Write((uint)16); // Header length
bw.Write(new byte[16]); // Header
bw.Write(zipBytes);
bw.Flush();
return ms.ToArray();
}
private static byte[] CreateManifestJson(string name, string version, string description)
{
var dict = new Dictionary<string, object>
{
["manifest_version"] = 3,
["name"] = name,
["version"] = version,
["description"] = description
};
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dict));
}
private static byte[] CreateMaliciousZipSlipBytes()
{
using var zipMs = new MemoryStream();
using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true))
{
var attackEntry = archive.CreateEntry("../../../../../etc/passwd");
using var s = attackEntry.Open();
s.Write(Encoding.UTF8.GetBytes("attack payload"));
}
var zipBytes = zipMs.ToArray();
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 });
bw.Write((uint)3);
bw.Write((uint)8);
bw.Write(new byte[8]);
bw.Write(zipBytes);
bw.Flush();
return ms.ToArray();
}
private sealed class TestExtensionHost : IBrowserExtensionHost
{
public Task<string?> AddExtensionAsync(string folderPath, string extensionId) => Task.FromResult<string?>(extensionId);
public Task<bool> RemoveExtensionAsync(string extensionId) => Task.FromResult(true);
public Task<bool> SetExtensionEnabledAsync(string extensionId, bool enabled) => Task.FromResult(true);
}
private sealed class TestScenarioQueueAdapter : IQueueAdapter
{
private readonly List<QueueItemDto> _items = new();
public int TotalOperations { get; private set; }
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(_items.ToList());
public Task<string> AddAsync(string url, string? title)
{
lock (_items)
{
var id = Guid.NewGuid().ToString("N");
_items.Add(new QueueItemDto(id, title ?? url, url, "다운로드중", 50));
TotalOperations++;
return Task.FromResult(id);
}
}
public Task PauseAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; }
public Task ResumeAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; }
public Task CancelAsync(string id) { lock (_items) { _items.RemoveAll(x => x.Id == id); TotalOperations++; } return Task.CompletedTask; }
public event Action? Changed { add { } remove { } }
}
private sealed class DirectPassthroughProcessRunner : Paca.Core.Platform.IProcessRunner
{
public Task<Paca.Core.Platform.ProcessResult> RunAsync(
Paca.Core.Platform.ProcessSpec spec,
Action<string, string>? onLine = null,
CancellationToken ct = default)
{
return Task.FromResult(new Paca.Core.Platform.ProcessResult(0, "OK", ""));
}
}
private sealed class FakeMediaMuxer : Paca.Core.Platform.IMediaMuxer
{
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
File.Copy(input, output, true);
return Task.CompletedTask;
}
public Task DownloadDashAsync(
string ffmpeg, Uri mpdUrl, string output, double durationSec,
HttpClient http, IProgress<Paca.Core.Models.DownloadProgress>? progress, CancellationToken ct)
{
return Task.CompletedTask;
}
public string? ResolveFfmpeg(Paca.Core.Config.AppConfig config) => "fake_ffmpeg.exe";
}
private sealed class SimpleTestHlsServer : IDisposable
{
private readonly HttpListener _listener;
private readonly int _segmentCount;
private readonly HashSet<int> _failedOnce = new();
private bool _disposed;
public bool InjectFaults { get; set; }
public SimpleTestHlsServer(int port, int segmentCount)
{
_segmentCount = segmentCount;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
}
public void Start()
{
_listener.Start();
Task.Run(ListenLoop);
}
private async Task ListenLoop()
{
while (!_disposed && _listener.IsListening)
{
try
{
var ctx = await _listener.GetContextAsync();
ProcessRequest(ctx);
}
catch { break; }
}
}
private void ProcessRequest(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url?.AbsolutePath ?? "";
if (path == "/playlist.m3u8")
{
var sb = new StringBuilder();
sb.AppendLine("#EXTM3U");
sb.AppendLine("#EXT-X-VERSION:3");
sb.AppendLine("#EXT-X-TARGETDURATION:2");
for (int i = 0; i < _segmentCount; i++)
{
sb.AppendLine("#EXTINF:2.0,");
sb.AppendLine($"segment_{i}.ts");
}
sb.AppendLine("#EXT-X-ENDLIST");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
ctx.Response.ContentType = "application/vnd.apple.mpegurl";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
ctx.Response.Close();
return;
}
if (path.StartsWith("/segment_") && path.EndsWith(".ts"))
{
var segNumStr = path.Replace("/segment_", "").Replace(".ts", "");
if (int.TryParse(segNumStr, out var segNum))
{
if (InjectFaults && segNum % 4 == 0 && _failedOnce.Add(segNum))
{
// 결함 주입: 500 에러 반환 후 다음 재시도에서 정상 제공
ctx.Response.StatusCode = 500;
ctx.Response.Close();
return;
}
// 정상 세그먼트 데이터 반환 (1KB)
var dummyBytes = new byte[1024];
Array.Fill(dummyBytes, (byte)(segNum & 0xFF));
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "video/mp2t";
ctx.Response.ContentLength64 = dummyBytes.Length;
ctx.Response.OutputStream.Write(dummyBytes, 0, dummyBytes.Length);
ctx.Response.Close();
return;
}
}
ctx.Response.StatusCode = 404;
ctx.Response.Close();
}
catch { }
}
public void Dispose()
{
_disposed = true;
try { _listener.Stop(); _listener.Close(); } catch { }
}
}
#endregion
}