feat: 完成v1.3.0架构对齐与Web端根路径托管全量开发
- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐 - 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转 - 实现种子数据初始化:添加默认管理员账号自动创建逻辑 - 优化前端代理与构建:新增Vite代理配置与自动构建Target - 补全测试与文档:新增集成测试,更新开发与部署文档
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user