releases/tests/Paca.Tests/ModernBrowsingMilestone11Phase111Tests.cs

532 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Paca.Browser.Url;
using Xunit;
namespace Paca.Tests;
public sealed class ModernBrowsingMilestone11Phase111Tests : IDisposable
{
private readonly string _tempDir;
public ModernBrowsingMilestone11Phase111Tests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "paca_m11_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true);
}
catch { }
}
[Fact]
public void Gate175_CleanLinkExtractor_StripsTrackingParamsAndUnpacksAmpUrls()
{
// Arrange
var extractor = new CleanLinkExtractor();
// Act 1: Strip UTM and social tracking parameters while keeping content params
var rawSocialUrl = "https://example.com/article?utm_source=twitter&utm_medium=social&utm_campaign=autumn_sale&id=456&utm_content=logolink";
var cleanedResult = extractor.Clean(rawSocialUrl);
// Assert 1
Assert.True(cleanedResult.WasModified);
Assert.False(cleanedResult.WasAmpUnpacked);
Assert.Equal("https://example.com/article?id=456", cleanedResult.CleanedUrl);
Assert.Contains("utm_source", cleanedResult.StrippedParameters);
Assert.Contains("utm_medium", cleanedResult.StrippedParameters);
Assert.Contains("utm_campaign", cleanedResult.StrippedParameters);
Assert.Contains("utm_content", cleanedResult.StrippedParameters);
Assert.DoesNotContain("id", cleanedResult.StrippedParameters);
// Act 2: Strip multiple ad tracker params (fbclid, gclid, msclkid, mc_cid)
var rawAdUrl = "https://shop.example.com/item/shoes?fbclid=IwAR123&gclid=Cj0K456&msclkid=789&mc_cid=abc&mc_eid=def";
var adResult = extractor.Clean(rawAdUrl);
// Assert 2: All query params were tracking, so clean URL has no query string
Assert.True(adResult.WasModified);
Assert.Equal("https://shop.example.com/item/shoes", adResult.CleanedUrl);
Assert.Equal(5, adResult.StrippedParameters.Count);
// Act 3: Preserve URL fragment/hash
var rawWithHash = "https://example.com/docs?utm_source=newsletter#section-installation";
var hashResult = extractor.Clean(rawWithHash);
Assert.Equal("https://example.com/docs#section-installation", hashResult.CleanedUrl);
// Act 4: Unpack Google AMP URLs to canonical destination
var googleAmpHttps = "https://www.google.com/amp/s/news.example.com/politics/2026-election";
var unpackedResult = extractor.Clean(googleAmpHttps);
Assert.True(unpackedResult.WasModified);
Assert.True(unpackedResult.WasAmpUnpacked);
Assert.Equal("https://news.example.com/politics/2026-election", unpackedResult.CleanedUrl);
Assert.Equal("https://news.example.com/politics/2026-election", unpackedResult.UnpackedCanonicalUrl);
// Act 5: Unpack AMP cache CDN and simultaneously strip tracking params
var ampCdnWithTracking = "https://example-com.cdn.ampproject.org/c/s/example.com/story?utm_source=amp_share&fbclid=xyz&page=2";
var combinedResult = extractor.Clean(ampCdnWithTracking);
Assert.True(combinedResult.WasModified);
Assert.True(combinedResult.WasAmpUnpacked);
Assert.Equal("https://example.com/story?page=2", combinedResult.CleanedUrl);
Assert.Contains("utm_source", combinedResult.StrippedParameters);
Assert.Contains("fbclid", combinedResult.StrippedParameters);
// Act 6: Clean non-tracking URL returns untouched
var pristineUrl = "https://clean.example.org/search?q=csharp&page=1";
var pristineResult = extractor.Clean(pristineUrl);
Assert.False(pristineResult.WasModified);
Assert.Equal(pristineUrl, pristineResult.CleanedUrl);
Assert.Empty(pristineResult.StrippedParameters);
// Act 7: Custom tracking parameters support
var customExtractor = new CleanLinkExtractor(new[] { "custom_trk", "affiliate_tracker" });
var customUrl = "https://store.example.com/book?custom_trk=99&title=tales";
var customResult = customExtractor.Clean(customUrl);
Assert.Equal("https://store.example.com/book?title=tales", customResult.CleanedUrl);
Assert.Contains("custom_trk", customResult.StrippedParameters);
}
[Fact]
public void Gate176_TextFragmentDeepLinker_GeneratesAndParsesTextFragments()
{
// Arrange
var linker = new TextFragmentDeepLinker();
// Act 1: Generate simple text fragment
var simpleLink = linker.GenerateLink("https://en.wikipedia.org/wiki/Browser", "Web browser");
Assert.Equal("https://en.wikipedia.org/wiki/Browser#:~:text=Web%20browser", simpleLink);
// Act 2: Generate text fragment with start and end range
var rangeLink = linker.GenerateLink("https://example.com/doc", "Getting Started", "Next Steps");
Assert.Equal("https://example.com/doc#:~:text=Getting%20Started,Next%20Steps", rangeLink);
// Act 3: Generate text fragment with context prefix and suffix
var contextLink = linker.GenerateLink(
"https://example.com/manual",
textStart: "Configure PACA",
textEnd: "Restart application",
prefix: "Step 1 -",
suffix: "- Complete");
Assert.Contains("#:~:text=", contextLink);
Assert.Contains("Step%201%20--", contextLink);
Assert.Contains("Configure%20PACA,Restart%20application", contextLink);
Assert.Contains("--%20Complete", contextLink);
// Act 4: Generate link preserving existing fragment hash
var hashBase = "https://example.com/terms#privacy";
var hashLink = linker.GenerateLink(hashBase, "Data Collection");
Assert.Equal("https://example.com/terms#privacy:~:text=Data%20Collection", hashLink);
// Act 5: Parse multi-directive text fragment URL
var complexUrl = "https://example.com/article#section1:~:text=Important%20notice&text=prefix-,highlight%20start,highlight%20end,-suffix";
var parsed = linker.Parse(complexUrl);
Assert.True(parsed.HasTextFragment);
Assert.Equal("https://example.com/article#section1", parsed.BaseUrlWithoutDirective);
Assert.Equal("section1", parsed.TraditionalFragment);
Assert.Equal(2, parsed.Directives.Count);
// Directive 1
Assert.Equal("Important notice", parsed.Directives[0].TextStart);
Assert.Null(parsed.Directives[0].TextEnd);
Assert.Null(parsed.Directives[0].Prefix);
Assert.Null(parsed.Directives[0].Suffix);
// Directive 2
Assert.Equal("prefix", parsed.Directives[1].Prefix);
Assert.Equal("highlight start", parsed.Directives[1].TextStart);
Assert.Equal("highlight end", parsed.Directives[1].TextEnd);
Assert.Equal("suffix", parsed.Directives[1].Suffix);
// Act 6: Strip text fragment directive, preserving traditional hash
var strippedWithHash = linker.RemoveTextFragments("https://example.com/doc#summary:~:text=Summary%20Text");
Assert.Equal("https://example.com/doc#summary", strippedWithHash);
var strippedWithoutHash = linker.RemoveTextFragments("https://example.com/doc#:~:text=Alone");
Assert.Equal("https://example.com/doc", strippedWithoutHash);
// Act 7: Generate DOM highlight script
var highlightScript = linker.GenerateHighlightScript(parsed.Directives);
Assert.Contains("window.find", highlightScript);
Assert.Contains("Important notice", highlightScript);
Assert.Contains("highlight start", highlightScript);
}
[Fact]
public void Gate177_UrlBreadcrumbNavigator_SplitsHierarchyAndGeneratesBreadcrumbs()
{
// Arrange
var navigator = new UrlBreadcrumbNavigator();
// Act 1: Parse hierarchical URL
var rawUrl = "https://github.com/dotnet/runtime/tree/main/src/libraries";
var breadcrumbs = navigator.ParseBreadcrumbs(rawUrl);
// Assert 1: 7 levels of breadcrumbs
Assert.Equal(7, breadcrumbs.Count);
Assert.Equal("github.com", breadcrumbs[0].Label);
Assert.Equal("https://github.com", breadcrumbs[0].Url);
Assert.Equal(0, breadcrumbs[0].Level);
Assert.True(breadcrumbs[0].IsHost);
Assert.False(breadcrumbs[0].IsCurrent);
Assert.Equal("dotnet", breadcrumbs[1].Label);
Assert.Equal("https://github.com/dotnet", breadcrumbs[1].Url);
Assert.Equal(1, breadcrumbs[1].Level);
Assert.False(breadcrumbs[1].IsHost);
Assert.Equal("runtime", breadcrumbs[2].Label);
Assert.Equal("https://github.com/dotnet/runtime", breadcrumbs[2].Url);
Assert.Equal("tree", breadcrumbs[3].Label);
Assert.Equal("https://github.com/dotnet/runtime/tree", breadcrumbs[3].Url);
Assert.Equal("main", breadcrumbs[4].Label);
Assert.Equal("https://github.com/dotnet/runtime/tree/main", breadcrumbs[4].Url);
Assert.Equal("src", breadcrumbs[5].Label);
Assert.Equal("https://github.com/dotnet/runtime/tree/main/src", breadcrumbs[5].Url);
Assert.Equal("libraries", breadcrumbs[6].Label);
Assert.Equal("https://github.com/dotnet/runtime/tree/main/src/libraries", breadcrumbs[6].Url);
Assert.Equal(6, breadcrumbs[6].Level);
Assert.True(breadcrumbs[6].IsCurrent);
// Act 2: Root URL breadcrumb
var rootBreadcrumbs = navigator.ParseBreadcrumbs("https://paca.browser.dev/");
Assert.Single(rootBreadcrumbs);
Assert.Equal("paca.browser.dev", rootBreadcrumbs[0].Label);
Assert.True(rootBreadcrumbs[0].IsHost);
Assert.True(rootBreadcrumbs[0].IsCurrent);
// Act 3: Parent URL navigation
var parentUrl = navigator.GetParentUrl("https://github.com/dotnet/runtime/tree");
Assert.Equal("https://github.com/dotnet/runtime", parentUrl);
var topParentUrl = navigator.GetParentUrl("https://github.com/dotnet");
Assert.Equal("https://github.com", topParentUrl);
var rootParentUrl = navigator.GetParentUrl("https://github.com");
Assert.Null(rootParentUrl);
// Act 4: Ancestor URLs collection
var ancestors = navigator.GetAncestorUrls("https://github.com/dotnet/runtime");
Assert.Equal(2, ancestors.Count);
Assert.Equal("https://github.com", ancestors[0]);
Assert.Equal("https://github.com/dotnet", ancestors[1]);
// Act 5: Percent-encoded segment decoding in labels
var encodedUrl = "https://example.org/files/My%20Project%20Files/Reports%26Docs";
var encodedCrumbs = navigator.ParseBreadcrumbs(encodedUrl);
Assert.Equal(4, encodedCrumbs.Count);
Assert.Equal("My Project Files", encodedCrumbs[2].Label);
Assert.Equal("Reports&Docs", encodedCrumbs[3].Label);
Assert.Equal("https://example.org/files/My%20Project%20Files", encodedCrumbs[2].Url);
}
[Fact]
public void Gate178_OmniboxQrCoordinator_GeneratesSvgQrCodeAndMobileHandoffLinks()
{
// Arrange
var coordinator = new OmniboxQrCoordinator();
// Act 1: Generate valid QR code SVG payload
var testUrl = "https://github.com/paca-browser/desktop";
var svg = coordinator.GenerateQrSvg(testUrl, moduleSize: 8);
// Assert 1
Assert.False(string.IsNullOrWhiteSpace(svg));
Assert.Contains("<svg", svg, StringComparison.OrdinalIgnoreCase);
Assert.Contains("http://www.w3.org/2000/svg", svg);
Assert.Contains("</svg>", svg, StringComparison.OrdinalIgnoreCase);
// Act 2: Custom theme styling (Dark mode SVG)
var darkSvg = coordinator.GenerateQrSvg(
testUrl,
moduleSize: 10,
foregroundColorHex: "#CDD6F4",
backgroundColorHex: "#1E1E2E");
Assert.Contains("#CDD6F4", darkSvg);
Assert.Contains("#1E1E2E", darkSvg);
// Act 3: Mobile handoff payload generation
var handoff = coordinator.CreateHandoffPayload(
"https://news.ycombinator.com/item?id=12345",
title: "Hacker News Story",
deviceId: "desktop-paca-win");
// Assert 3
Assert.Equal("https://news.ycombinator.com/item?id=12345", handoff.TargetUrl);
Assert.Equal("Hacker News Story", handoff.Title);
Assert.Equal("desktop-paca-win", handoff.DeviceId);
Assert.StartsWith("paca://handoff?", handoff.HandoffDeepLink);
Assert.Contains("url=" + Uri.EscapeDataString("https://news.ycombinator.com/item?id=12345"), handoff.HandoffDeepLink);
Assert.Contains("<svg", handoff.QrCodeSvg);
Assert.True(handoff.Timestamp <= DateTimeOffset.UtcNow);
// Act 4: Parse back mobile handoff deep link
var parseSuccess = coordinator.TryParseHandoffLink(
handoff.HandoffDeepLink,
out var parsedUrl,
out var parsedTitle,
out var parsedDeviceId);
Assert.True(parseSuccess);
Assert.Equal(handoff.TargetUrl, parsedUrl);
Assert.Equal(handoff.Title, parsedTitle);
Assert.Equal(handoff.DeviceId, parsedDeviceId);
// Act 5: Validate URL
Assert.True(coordinator.ValidateUrl("https://example.com"));
Assert.True(coordinator.ValidateUrl("http://localhost:5000/api"));
Assert.False(coordinator.ValidateUrl("invalid-not-a-url"));
Assert.False(coordinator.ValidateUrl(""));
Assert.Throws<ArgumentException>(() => coordinator.GenerateQrSvg("not a valid url"));
}
[Fact]
public async Task Gate179_QuickDictionaryLookup_ResolvesWordDefinitionsAndTranslations()
{
// Arrange
var lookup = new QuickDictionaryLookup();
// Act 1: Text sanitization (double-click trims punctuation, quotes, whitespace)
var sanitized = lookup.SanitizeWord(" \"omnibox,\" ");
Assert.Equal("omnibox", sanitized);
var hyphenated = lookup.SanitizeWord("(browser-engine!)");
Assert.Equal("browser-engine", hyphenated);
// Act 2: Built-in vocabulary lookup (English -> Korean translation)
var omniboxResult = lookup.Lookup("omnibox", targetLanguage: "ko");
// Assert 2
Assert.True(omniboxResult.Found);
Assert.Equal("omnibox", omniboxResult.Word);
Assert.NotNull(omniboxResult.Phonetic);
Assert.NotEmpty(omniboxResult.Meanings);
Assert.Contains(omniboxResult.Meanings, m => m.PartOfSpeech.Equals("noun", StringComparison.OrdinalIgnoreCase));
Assert.True(omniboxResult.Translations.ContainsKey("ko"));
Assert.Contains("주소창", omniboxResult.Translations["ko"]);
// Act 3: Lookup standard word with definitions and sample sentences
var browserResult = lookup.Lookup("browser", targetLanguage: "ko");
Assert.True(browserResult.Found);
Assert.NotNull(browserResult.Phonetic);
Assert.Contains(browserResult.Meanings, m => !string.IsNullOrEmpty(m.Example));
Assert.Equal("웹 브라우저", browserResult.Translations["ko"]);
// Act 4: Custom vocabulary registration
lookup.RegisterDefinition(
word: "hypersync",
phonetic: "/ˈhaɪ.pər.sɪŋk/",
meanings: new[]
{
new WordMeaning("noun", "A real-time state synchronization protocol for distributed browsers.", "PACA uses hypersync for tab handoff.")
},
translations: new Dictionary<string, string>
{
["ko"] = "고속 동기화",
["ja"] = "高速同期"
});
var hypersyncResult = lookup.Lookup("hypersync", targetLanguage: "ja");
Assert.True(hypersyncResult.Found);
Assert.Equal("高速同期", hypersyncResult.Translations["ja"]);
// Act 5: Unknown word graceful fallback
var unknownResult = lookup.Lookup("xyznonexistentword999");
Assert.False(unknownResult.Found);
Assert.Empty(unknownResult.Meanings);
Assert.Empty(unknownResult.Translations);
// Act 6: Async lookup with cancellation token
var asyncResult = await lookup.LookupAsync("cache", "ko");
Assert.True(asyncResult.Found);
Assert.Contains("캐시", asyncResult.Translations["ko"]);
}
[Fact]
public void Gate180_CaretBrowsingCoordinator_HandlesF7ToggleAndCursorNavigation()
{
// Arrange
var coordinator = new CaretBrowsingCoordinator();
string tabId = "tab-window-77";
// Assert initial state: Caret browsing disabled
Assert.False(coordinator.IsEnabled(tabId));
// Act 1: Toggle Caret Browsing with F7 key event
var eventFired = false;
CaretState? observedState = null;
coordinator.StateChanged += (sender, e) =>
{
eventFired = true;
observedState = e.State;
};
var keyResult1 = coordinator.HandleKeyDown(tabId, "F7");
// Assert 1: Toggled ON
Assert.True(keyResult1.Handled);
Assert.True(coordinator.IsEnabled(tabId));
Assert.True(eventFired);
Assert.NotNull(observedState);
Assert.True(observedState.IsEnabled);
// Act 2: Move cursor with arrow keys
coordinator.SetPosition(tabId, line: 10, column: 5, offset: 120);
var initialPos = coordinator.GetPosition(tabId);
Assert.Equal(10, initialPos.Line);
Assert.Equal(5, initialPos.Column);
Assert.Equal(120, initialPos.AbsoluteOffset);
// Right arrow moves column forward
var rightResult = coordinator.HandleKeyDown(tabId, "ArrowRight");
Assert.True(rightResult.Handled);
var posAfterRight = coordinator.GetPosition(tabId);
Assert.Equal(6, posAfterRight.Column);
Assert.Equal(121, posAfterRight.AbsoluteOffset);
// Left arrow moves column backward
coordinator.HandleKeyDown(tabId, "ArrowLeft");
var posAfterLeft = coordinator.GetPosition(tabId);
Assert.Equal(5, posAfterLeft.Column);
// Down arrow moves line down
coordinator.HandleKeyDown(tabId, "ArrowDown");
var posAfterDown = coordinator.GetPosition(tabId);
Assert.Equal(11, posAfterDown.Line);
// Up arrow moves line up
coordinator.HandleKeyDown(tabId, "ArrowUp");
var posAfterUp = coordinator.GetPosition(tabId);
Assert.Equal(10, posAfterUp.Line);
// Act 3: Shift + Arrow navigation expands selection
coordinator.SetPosition(tabId, line: 1, column: 0, offset: 0);
coordinator.HandleKeyDown(tabId, "ArrowRight", shiftKey: true);
coordinator.HandleKeyDown(tabId, "ArrowRight", shiftKey: true);
coordinator.HandleKeyDown(tabId, "ArrowRight", shiftKey: true);
var selection = coordinator.GetSelection(tabId);
Assert.Equal(0, selection.StartOffset);
Assert.Equal(3, selection.EndOffset);
Assert.Equal(3, selection.Length);
// Regular navigation without shift clears selection
coordinator.HandleKeyDown(tabId, "ArrowRight", shiftKey: false);
var clearedSelection = coordinator.GetSelection(tabId);
Assert.Equal(0, clearedSelection.Length);
// Act 4: Generate WebView2 injection script for caret overlay
var enableScript = coordinator.GenerateInjectionScript(enabled: true);
Assert.Contains("paca-caret-cursor", enableScript);
Assert.Contains("addEventListener('keydown'", enableScript);
var disableScript = coordinator.GenerateInjectionScript(enabled: false);
Assert.Contains("remove()", disableScript);
// Act 5: Toggle Caret Browsing OFF
var keyResult2 = coordinator.HandleKeyDown(tabId, "F7");
Assert.True(keyResult2.Handled);
Assert.False(coordinator.IsEnabled(tabId));
}
[Fact]
public void Gate181_HttpResponseHeadersViewer_ParsesAndAnalyzesSecurityAndCacheHeaders()
{
// Arrange
var viewer = new HttpResponseHeadersViewer();
// Act 1: Analyze secure response headers (HSTS, CSP, X-Frame-Options, X-Content-Type, Cache-Control)
var secureHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload",
["Content-Security-Policy"] = "default-src 'self'; script-src 'self' https://trusted.cdn.com; frame-ancestors 'none'; upgrade-insecure-requests",
["X-Frame-Options"] = "DENY",
["X-Content-Type-Options"] = "nosniff",
["Referrer-Policy"] = "strict-origin-when-cross-origin",
["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()",
["Cache-Control"] = "public, max-age=86400, must-revalidate",
["Content-Type"] = "text/html; charset=utf-8",
["Server"] = "cloudflare"
};
var analysis = viewer.Analyze(200, "OK", secureHeaders, protocol: "HTTP/2");
// Assert 1: Status & Summary
Assert.Equal(200, analysis.StatusCode);
Assert.Equal("OK", analysis.StatusDescription);
Assert.Equal("HTTP/2", analysis.Protocol);
Assert.NotEmpty(analysis.FormattedHeaders);
// Assert 1: Cache analysis
Assert.True(analysis.CacheAnalysis.IsCacheable);
Assert.True(analysis.CacheAnalysis.IsPublic);
Assert.False(analysis.CacheAnalysis.IsNoStore);
Assert.True(analysis.CacheAnalysis.IsMustRevalidate);
Assert.Equal(86400, analysis.CacheAnalysis.MaxAgeSeconds);
// Assert 1: Security audit & scoring
var sec = analysis.SecurityAudit;
Assert.True(sec.Hsts.IsPresent);
Assert.Equal(31536000, sec.Hsts.MaxAgeSeconds);
Assert.True(sec.Hsts.IncludeSubDomains);
Assert.True(sec.Hsts.Preload);
Assert.True(sec.Hsts.MeetsStrictCriteria);
Assert.True(sec.Csp.IsPresent);
Assert.True(sec.Csp.RestrictsScriptSrc);
Assert.True(sec.Csp.RestrictsFrameAncestors);
Assert.True(sec.Csp.UpgradesInsecureRequests);
Assert.Equal("DENY", sec.XFrameOptions);
Assert.Equal("nosniff", sec.XContentTypeOptions);
Assert.Equal("strict-origin-when-cross-origin", sec.ReferrerPolicy);
Assert.True(sec.SecurityScore >= 90);
Assert.Equal("A+", sec.Grade);
Assert.NotEmpty(sec.Strengths);
Assert.Empty(sec.Recommendations);
// Act 2: Analyze insecure / missing headers response
var insecureHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Cache-Control"] = "no-store, no-cache",
["Content-Type"] = "text/html"
};
var insecureAnalysis = viewer.Analyze(200, "OK", insecureHeaders, protocol: "HTTP/1.1");
// Assert 2: Insecure analysis
Assert.False(insecureAnalysis.CacheAnalysis.IsCacheable);
Assert.True(insecureAnalysis.CacheAnalysis.IsNoStore);
Assert.True(insecureAnalysis.CacheAnalysis.IsNoCache);
var insecureSec = insecureAnalysis.SecurityAudit;
Assert.False(insecureSec.Hsts.IsPresent);
Assert.False(insecureSec.Csp.IsPresent);
Assert.Null(insecureSec.XFrameOptions);
Assert.True(insecureSec.SecurityScore < 35);
Assert.Equal("F", insecureSec.Grade);
Assert.Contains(insecureSec.Recommendations, r => r.Contains("HSTS", StringComparison.OrdinalIgnoreCase));
Assert.Contains(insecureSec.Recommendations, r => r.Contains("CSP", StringComparison.OrdinalIgnoreCase));
// Act 3: Formatted raw text summary output
var rawText = viewer.FormatAsRawText(200, "OK", secureHeaders, protocol: "HTTP/2");
Assert.StartsWith("HTTP/2 200 OK", rawText);
Assert.Contains("Strict-Transport-Security:", rawText);
Assert.Contains("Cache-Control:", rawText);
}
}