Files
Hua.Todo/test/Hua.Todo.Host.Tests/CloudSync/HostBootstrapAndAuthRedirectTests.cs
T
ShaoHua 3213d4c718 feat: 完成v1.3.0架构对齐与Web端根路径托管全量开发
- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐
- 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转
- 实现种子数据初始化:添加默认管理员账号自动创建逻辑
- 优化前端代理与构建:新增Vite代理配置与自动构建Target
- 补全测试与文档:新增集成测试,更新开发与部署文档
2026-08-08 07:33:44 +08:00

246 lines
12 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.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// 2026-08-08 用户反馈修复的 ATDD 验收测试:
/// 1. [根路径 404] 前端未构建(wwwroot/spa 无 index.html)时,访问 / 和 /settings 不 404,
/// 返回 MinimalBootstrapPageMiddleware 的最小引导页(含中文错误提示 + 构建命令)
/// 2. [无权限跳转登录] 浏览器 Accept: text/html 直访需要权限的 /api/*,DynamicApi 401/403
/// 应 302 跳 "/"(Host 双角色统一部署:前端 SPA 加载后显示登录弹窗 CloudSyncSettingsDialog)
/// 3. [不破坏现有 API] Accept: application/json 的 axios/fetch 调用需要权限的 /api/*,
/// 返回原有的 401/403 JSON(不 302,否则前端拦截器无法判断)
/// </summary>
public sealed class HostBootstrapAndAuthRedirectTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly string _baseDir;
private readonly string _wwwrootDir;
private readonly HttpClient _client;
private readonly WebApplication _app;
public HostBootstrapAndAuthRedirectTests()
{
_baseDir = AppContext.BaseDirectory;
_wwwrootDir = Path.Combine(_baseDir, "wwwroot");
Directory.CreateDirectory(_wwwrootDir);
// 模拟"前端未构建"场景:删掉 wwwroot/spa/index.html(如果存在)
var idx = Path.Combine(_wwwrootDir, "spa", "index.html");
if (File.Exists(idx)) File.Delete(idx);
_connection = new SqliteConnection("Data Source=HostBootstrapAuthRedirectTests;Mode=Memory;Cache=Shared");
_connection.Open();
_app = BuildHost(environmentName: "Development");
_client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
BaseAddress = new Uri(_app.Urls.First()),
};
}
private WebApplication BuildHost(string environmentName)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.EnvironmentName = environmentName;
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplicationServices("Data Source=HostBootstrapAuthRedirectTests;Mode=Memory;Cache=Shared");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
builder.Services.AddCloudSyncServer();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
// 与 Program.cs 完全对齐:MinimalBootstrapPageMiddleware 放在 UseDefaultFiles/UseStaticFiles 之前
// (注意:本测试类中 Program.cs 里的内部类型 MinimalBootstrapPageMiddleware 不可见,
// 这里用等价实现的内联委托替代,保持相同判断规则 + 相同 HTML 特征字)
app.Use(async (context, next) =>
{
await next();
// 相同判断规则:
var indexHtmlPath = Path.Combine(app.Environment.WebRootPath, "spa", "index.html");
if (File.Exists(indexHtmlPath)) return;
if (context.Response.StatusCode != StatusCodes.Status404NotFound) return;
var method = context.Request.Method;
if (!HttpMethods.IsGet(method) && !HttpMethods.IsHead(method)) return;
var path = context.Request.Path.Value ?? string.Empty;
if (path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/mcp", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase))
return;
var accept = context.Request.Headers["Accept"].ToString();
if (string.IsNullOrEmpty(accept) ||
!accept.Contains("text/html", StringComparison.OrdinalIgnoreCase))
return;
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = "text/html; charset=utf-8";
// 特征字:用于测试断言,不需要完整样式
await context.Response.WriteAsync(
"<!doctype html><title>Hua.Todo - 前端尚未构建</title>" +
"<body><span>前端尚未构建</span>" +
"<code>dotnet build src/Hua.Todo.Host</code></body></html>");
});
app.UseDefaultFiles();
app.UseStaticFiles();
// SPA 静态资源映射(与 Program.cs 对齐)
var spaRoot = Path.Combine(app.Environment.WebRootPath, "spa");
if (Directory.Exists(spaRoot))
{
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(spaRoot) });
app.UseStaticFiles(new StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(spaRoot) });
}
app.UseAuthentication();
app.UseAuthorization();
app.UseDynamicApi();
app.MapFallbackToFile("spa/index.html");
app.StartAsync().GetAwaiter().GetResult();
return app;
}
[Fact]
public async Task Root_Get_NoIndexHtml_AcceptHtml_Returns200BootstrapPage_Not404()
{
// Given: wwwroot/spa/index.html 不存在(构造函数中已删除)
// When : 浏览器 GET / Accept: text/html
// Then : 200 + text/html + body 含「前端尚未构建」特征字
using var req = new HttpRequestMessage(HttpMethod.Get, "/");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/html",
response.Content.Headers.ContentType?.MediaType ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("前端尚未构建", body, StringComparison.Ordinal);
Assert.Contains("dotnet build src/Hua.Todo.Host", body, StringComparison.Ordinal);
}
[Fact]
public async Task SpaRouteSettings_Get_NoIndexHtml_AcceptHtml_Returns200BootstrapPage_Not404()
{
// Given: 同上
// When : 浏览器 GET /settings Accept: text/html
// Then : 同上 200 + 引导页(SPA 路由不 404)
using var req = new HttpRequestMessage(HttpMethod.Get, "/settings");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("前端尚未构建", body, StringComparison.Ordinal);
}
[Fact]
public async Task DynamicApi_RequirePermission_Get_BrowserAcceptHtml_401_Returns302RedirectToRoot()
{
// Given: 未登录 + 明确标注 [RequirePermission] 的 /api/security/policy
// (ISecurityPolicyService 配置:[DynamicApiRoute("security")] + [HttpGet("policy")]
// + [RequirePermission(CloudPermissions.PolicyRead)])
// When : 浏览器 GET /api/security/policy Accept: text/html
// Then : 302 + Location = "/"(Host 提供前端 SPA,前端判断未登录 → CloudSyncSettingsDialog)
using var req = new HttpRequestMessage(HttpMethod.Get, "/api/security/policy");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,application/json;q=0.8,*/*;q=0.7");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Equal("/", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task DynamicApi_RequirePermission_Get_AcceptJson_401_Returns401Json_NotRedirect()
{
// Given: 同上,Accept: application/json(axios/fetch 模式)
// When : GET /api/security/policy Accept: application/json
// Then : 401 + application/json + body.success=false(不破坏现有前端拦截器逻辑)
using var req = new HttpRequestMessage(HttpMethod.Get, "/api/security/policy");
req.Headers.Accept.ParseAdd("application/json, text/plain, */*");
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.StartsWith("application/json",
response.Content.Headers.ContentType?.MediaType ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("\"success\":false", body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("未登录", body, StringComparison.Ordinal);
}
[Fact]
public async Task DynamicApi_RequirePermission_Put_BrowserAcceptHtml_401_Still401Json_NotRedirect()
{
// Given: 未登录 PUT /api/security/policy(RequirePermission 要求 PolicyWrite)
// Rule : IsBrowserHtmlRequest 只对 GET/HEAD 生效 → PUT/POST/PATCH/DELETE → 永远返回 JSON 401/403
// When : PUT /api/security/policy,Accept: text/html(模拟不常见场景:浏览器 PUT 提交)
// Then : 401 JSON(不 302,因为不是 GET/HEAD)
using var req = new HttpRequestMessage(HttpMethod.Put, "/api/security/policy");
req.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
req.Content = JsonContent.Create(new { AllowPersist = true, AllowSync = true });
using var response = await _client.SendAsync(req);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.StartsWith("application/json",
response.Content.Headers.ContentType?.MediaType ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Api_Probe_Post_NoAuth_Anonymous_StillWorks_NotRedirectOr401()
{
// 回归:不影响已有的匿名 DynamicApi 端点(/probe 本来就 AllowAnonymous)
using var response = await _client.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.NotEqual(HttpStatusCode.Redirect, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("isReachable", body, StringComparison.OrdinalIgnoreCase);
}
public void Dispose()
{
_client.Dispose();
try { _app.StopAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
try { _app.DisposeAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
_connection.Dispose();
}
}