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("