using System.Net; using System.Text; using MoShiro.Backend.Api; namespace MoShiro.Backend.Tests; /// /// 内嵌前端:白名单、不碰文件系统,路径穿越的各种写法全部 404;SPA 回退只给没有扩展名的路径,不吞 API 的 404; /// 页面带 CSP 等响应头,哈希文件名的资源长缓存、入口页不缓存。 /// public sealed class StaticSiteTests { private static StaticSite Site() => new( [ ("/index.html", Encoding.UTF8.GetBytes("display")), ("/assets/app-1a2b.js", Encoding.UTF8.GetBytes("console.log(1)")), ("/flags/cn.svg", Encoding.UTF8.GetBytes("")), ("/admin/index.html", Encoding.UTF8.GetBytes("admin")), ("/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("display", Text(site.Resolve("/").Asset)); Assert.Equal("admin", 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("display", Text(site.Resolve("/lines/abc").Asset)); Assert.Equal("admin", Text(site.Resolve("/admin/agents").Asset)); Assert.Equal("admin", 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("display", Text(site.Resolve("/administrator").Asset)); Assert.Equal("display", 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" 没登记 } } /// HTTP 层:后端自己的路径不会被前端的回退吞掉;响应头对。 [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("