Files
MoShiro/backend/tests/MoShiro.Backend.Tests/StaticSiteTests.cs
T

228 lines
8.9 KiB
C#

using System.Net;
using System.Text;
using MoShiro.Backend.Api;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 内嵌前端:白名单、不碰文件系统,路径穿越的各种写法全部 404;SPA 回退只给没有扩展名的路径,不吞 API 的 404;
/// 页面带 CSP 等响应头,哈希文件名的资源长缓存、入口页不缓存。
/// </summary>
public sealed class StaticSiteTests
{
private static StaticSite Site() => new(
[
("/index.html", Encoding.UTF8.GetBytes("<html>display</html>")),
("/assets/app-1a2b.js", Encoding.UTF8.GetBytes("console.log(1)")),
("/flags/cn.svg", Encoding.UTF8.GetBytes("<svg/>")),
("/admin/index.html", Encoding.UTF8.GetBytes("<html>admin</html>")),
("/admin/assets/app-3c4d.css", Encoding.UTF8.GetBytes("body{}")),
("/../secret.txt", Encoding.UTF8.GetBytes("must never be registered")),
]);
private static string Text(SiteAsset? asset) => Encoding.UTF8.GetString(asset!.Content);
[Fact]
public void KnownFiles_AreServed_WithTypeAndCaching()
{
StaticSite site = Site();
Assert.Equal("<html>display</html>", Text(site.Resolve("/").Asset));
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/").Asset));
SiteAsset? js = site.Resolve("/assets/app-1a2b.js").Asset;
Assert.StartsWith("text/javascript", js!.ContentType, StringComparison.Ordinal);
Assert.Contains("immutable", js.CacheControl, StringComparison.Ordinal);
Assert.False(js.Html);
SiteAsset? index = site.Resolve("/index.html").Asset;
Assert.Equal("no-cache", index!.CacheControl);
Assert.True(index.Html);
Assert.Equal("image/svg+xml", site.Resolve("/flags/cn.svg").Asset!.ContentType);
Assert.StartsWith("text/css", site.Resolve("/admin/assets/app-3c4d.css").Asset!.ContentType, StringComparison.Ordinal);
}
[Fact]
public void AdminWithoutSlash_RedirectsToAFixedPath()
{
SiteResolution resolution = Site().Resolve("/admin");
Assert.Null(resolution.Asset);
Assert.Equal("/admin/", resolution.Redirect);
}
[Theory]
[InlineData("/../secret.txt")]
[InlineData("/admin/../../etc/passwd")]
[InlineData("/assets/../index.html")]
[InlineData("/./index.html")]
[InlineData("/%2e%2e/secret.txt")]
[InlineData("/%2E%2E%2Fsecret.txt")]
[InlineData("/..%2fsecret.txt")]
[InlineData("/..%5csecret.txt")]
[InlineData("/%252e%252e/secret.txt")]
[InlineData("/..\\secret.txt")]
[InlineData("/assets\\app-1a2b.js")]
[InlineData("/assets//app-1a2b.js")]
[InlineData("//assets/app-1a2b.js")]
[InlineData("/index.html\0.png")]
[InlineData("/index.html\r\nSet-Cookie: a=b")]
[InlineData("/文件")]
[InlineData("assets/app-1a2b.js")]
[InlineData("")]
public void TraversalAndMalformedPaths_AreNotFound_NeverNormalized(string path)
{
SiteResolution resolution = Site().Resolve(path);
Assert.Null(resolution.Asset);
Assert.Null(resolution.Redirect);
}
[Fact]
public void OverlongPath_IsNotFound()
{
Assert.Null(Site().Resolve("/" + new string('a', 5000)).Asset);
}
[Fact]
public void SpaFallback_OnlyForPathsWithoutAnExtension()
{
StaticSite site = Site();
Assert.Equal("<html>display</html>", Text(site.Resolve("/lines/abc").Asset));
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/agents").Asset));
Assert.Equal("<html>admin</html>", Text(site.Resolve("/admin/system/trust").Asset));
// 缺失的文件不是页面:脚本、样式、图片 404 要保持 404,不能变成 HTML
Assert.Null(site.Resolve("/assets/missing.js").Asset);
Assert.Null(site.Resolve("/admin/assets/missing.css").Asset);
Assert.Null(site.Resolve("/favicon.ico").Asset);
Assert.Null(site.Resolve("/robots.txt").Asset);
}
[Fact]
public void AdminAndDisplay_AreNotMixed()
{
StaticSite site = Site();
// 展示端的路径永远不会得到管理端的页面,反过来管理端的前缀之外也一样
Assert.Equal("<html>display</html>", Text(site.Resolve("/administrator").Asset));
Assert.Equal("<html>display</html>", Text(site.Resolve("/adminx/agents").Asset));
Assert.Null(site.Resolve("/admin/index.htm").Asset);
}
[Fact]
public void EmptySite_ServesNothing()
{
var site = new StaticSite([]);
Assert.Equal(0, site.Count);
Assert.Null(site.Resolve("/").Asset);
Assert.Null(site.Resolve("/admin/agents").Asset);
Assert.Null(site.Resolve("/admin").Redirect);
}
[Fact]
public void UncleanPathsAreRejectedWhenRegistering()
{
StaticSite site = Site();
Assert.Equal(5, site.Count - 2); // 五个干净的文件;两个别名 "/" 和 "/admin/" 不算;"/../secret.txt" 没登记
}
}
/// <summary>HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。</summary>
[Collection("probe-e2e")]
public sealed class StaticSiteHttpTests
{
private readonly BackendFixture _backend;
public StaticSiteHttpTests(BackendFixture backend)
{
_backend = backend;
}
[Theory]
[InlineData("/api/admin/does-not-exist")]
[InlineData("/api/public/does-not-exist")]
[InlineData("/api/agent/v1/nope")]
[InlineData("/api")]
[InlineData("/openapi/missing.json")]
[InlineData("/install/missing")]
[InlineData("/healthz/x")]
public async Task BackendPaths_KeepTheirOwnNotFound_NotTheFrontend(string path)
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage response = await client.GetAsync(path);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
string body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("<html", body, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("/api/admin/agents")]
[InlineData("/healthz")]
public async Task ExistingEndpoints_AreNotShadowedByTheCatchAll(string path)
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage response = await client.GetAsync(path);
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
Assert.DoesNotContain("<html", await response.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("/../etc/passwd")]
[InlineData("/%2e%2e/etc/passwd")]
[InlineData("/admin/..%2f..%2fetc/passwd")]
[InlineData("/..%5c..%5cwindows/win.ini")]
public async Task Traversal_NeverReturnsAFileFromTheHost(string path)
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage response = await client.GetAsync(path);
string body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("root:", body, StringComparison.Ordinal);
Assert.DoesNotContain("[fonts]", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task WritesToTheSite_AreRejected()
{
using HttpClient client = _backend.NewClient();
foreach (HttpMethod method in new[] { HttpMethod.Post, HttpMethod.Put, HttpMethod.Delete, HttpMethod.Patch })
{
using var request = new HttpRequestMessage(method, "/admin/index.html");
using HttpResponseMessage response = await client.SendAsync(request);
Assert.True(response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotFound, $"{method}: {response.StatusCode}");
}
}
[Fact]
public async Task Pages_CarryTheSecurityHeaders_WhenTheFrontendIsEmbedded()
{
using HttpClient client = _backend.NewClient();
using HttpResponseMessage response = await client.GetAsync("/admin/");
if (response.StatusCode == HttpStatusCode.NotFound)
{
return; // 本地没构建前端时不嵌入(CI 的 Backend 测试同样没有);响应头的逻辑由上面的单元测试和发布构建覆盖
}
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
string csp = response.Headers.GetValues("Content-Security-Policy").Single();
Assert.Contains("script-src 'self'", csp, StringComparison.Ordinal);
Assert.Contains("frame-ancestors 'none'", csp, StringComparison.Ordinal);
Assert.Equal("no-cache", response.Headers.CacheControl!.ToString());
string etag = response.Headers.ETag!.ToString();
using var conditional = new HttpRequestMessage(HttpMethod.Get, "/admin/");
conditional.Headers.TryAddWithoutValidation("If-None-Match", etag);
using HttpResponseMessage notModified = await client.SendAsync(conditional);
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
}
}