feat: 完成v1.3.0架构对齐与Web端根路径托管全量开发

- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐
- 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转
- 实现种子数据初始化:添加默认管理员账号自动创建逻辑
- 优化前端代理与构建:新增Vite代理配置与自动构建Target
- 补全测试与文档:新增集成测试,更新开发与部署文档
This commit is contained in:
ShaoHua
2026-08-08 07:33:44 +08:00
parent 4fe0b5a963
commit 3213d4c718
32 changed files with 1937 additions and 86 deletions
@@ -6,7 +6,7 @@ using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Core.Entities;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services;
using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Services;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services;
using Hua.Todo.HttpApi.AspNetCore.CloudSync;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
@@ -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();
}
}
@@ -0,0 +1,155 @@
using System.Net;
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.Hosting;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// Host HTTP 管道行为测试。
/// 针对用户反馈的「前端云同步保存并探测报 Request failed with status code 307」。
///
/// 正确探测链路(嵌入式模式 MAUI / Avalonia):
/// 前端 POST /api/cloud-sync/probe {targetUrl}
/// → Vite proxy (target=http://localhost:5057)
/// → MAUI / Avalonia EmbeddedWebServer
/// → CloudSyncProxyMiddleware / CloudProbeService
/// → 后端 HttpClient 探测 {targetUrl}/api
/// 307 若出现,来自前端请求本身被某一层 HTTPS 重定向(Host 侧 UseHttpsRedirection),
/// 而非探测响应中的 HTTP 状态码。
///
/// 纯 Host 独立模式(VITE_API_TARGET 指向 Host)也必须保证匿名端点 HTTP 下不 307。
/// 因此 Host Program.cs 在开发环境不启用 UseHttpsRedirection。
/// </summary>
public sealed class HostHttpPipelineAnonymousEndpointTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly HttpClient _clientDev; // 开发环境(修复后:不启用 UseHttpsRedirection)
private readonly HttpClient _clientProd; // 生产环境(显式启用 UseHttpsRedirection,仅回归验证)
private readonly WebApplication _appDev;
private readonly WebApplication _appProd;
public HostHttpPipelineAnonymousEndpointTests()
{
_connection = new SqliteConnection("Data Source=HostHttpPipelineTests;Mode=Memory;Cache=Shared");
_connection.Open();
_appDev = BuildHost(environmentName: "Development", enableHttpsRedirection: false);
_appProd = BuildHost(environmentName: "Production", enableHttpsRedirection: true);
_clientDev = new HttpClient { BaseAddress = new Uri(_appDev.Urls.First()) };
_clientProd = new HttpClient { BaseAddress = new Uri(_appProd.Urls.First()) };
// 自动跟随重定向会隐藏 307,手动关掉以捕获原始状态码
_clientDev = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{ BaseAddress = new Uri(_appDev.Urls.First()) };
_clientProd = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{ BaseAddress = new Uri(_appProd.Urls.First()) };
}
private WebApplication BuildHost(string environmentName, bool enableHttpsRedirection)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.EnvironmentName = environmentName;
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddApplicationServices("Data Source=HostHttpPipelineTests;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();
}
// 与修复后的 Program.cs 逻辑一致:仅非开发环境启用 HTTPS 重定向
if (enableHttpsRedirection)
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseDynamicApi();
app.StartAsync().GetAwaiter().GetResult();
return app;
}
// ===== 开发环境(修复后):匿名端点 HTTP 下不得重定向 =====
[Fact]
public async Task DevEnv_ProbeEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_AuthLoginEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/auth/login",
new { userName = "admin", password = "x" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_AuthBootstrapEndpoint_Http_NoRedirect()
{
var response = await _clientDev.PostAsJsonAsync("/api/auth/bootstrap",
new { userName = "admin", password = "Admin@123" });
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
[Fact]
public async Task DevEnv_CloudSyncProxySettingsEndpoint_Http_NoRedirect()
{
var response = await _clientDev.GetAsync("/api/cloud-sync-proxy-settings");
Assert.NotEqual(HttpStatusCode.TemporaryRedirect, response.StatusCode);
Assert.False(IsRedirect(response.StatusCode));
}
// ===== 生产环境:启用 HTTPS 重定向,则 307(回归测试) =====
[Fact]
public async Task ProdEnv_ProbeEndpoint_Http_Returns307TemporaryRedirect()
{
var response = await _clientProd.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
// 生产启用 HTTPS 重定向 → HTTP POST 应触发 307。部署时由反向代理处理,此测试仅保证行为可预测。
Assert.Equal(HttpStatusCode.TemporaryRedirect, response.StatusCode);
}
public void Dispose()
{
_clientDev.Dispose();
_clientProd.Dispose();
foreach (var app in new[] { _appDev, _appProd })
{
app.StopAsync().GetAwaiter().GetResult();
app.DisposeAsync().GetAwaiter().GetResult();
}
_connection.Dispose();
}
private static bool IsRedirect(HttpStatusCode code) =>
(int)code is >= 300 and < 400;
}
@@ -0,0 +1,189 @@
using System.Net;
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.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>
/// 模式 B(Web 端)根路径托管 Hua.Todo.Web 静态页的验收测试。
///
/// 依据 v1.3.0 工单 06 的 6 条 Given-When-Then:
/// 1. 根路径 / 返回 200 + HTML(含 &lt;div id="app"&gt;)
/// 2. SPA 内部路由 /settings fallback 到 index.html
/// 3. /api/* 动态 API 不被 fallback 覆盖
/// 4. /swagger 不被 fallback 覆盖(中间件顺序保证)
///
/// RED 阶段:当前 Program.cs 没有 UseDefaultFiles / UseStaticFiles / MapFallbackToFile,
/// 因此根路径断言会失败(404,body 非 HTML)。
/// GREEN 阶段:按工单 06 §五 5.1 的 Program.cs 顺序补齐中间件后,全部通过。
/// </summary>
public sealed class WebRootStaticFileTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly string _webRootDir;
private readonly HttpClient _client;
private readonly WebApplication _app;
/// <summary>
/// 写入到临时 wwwroot/index.html 的特征字符串,供断言用。
/// 避免与实际 Hua.Todo.Web 构建耦合(测试不需要真的 npm build)。
/// </summary>
private const string SpaMountPoint = "<div id=\"app\">SpaMount</div>";
private const string SpaTitle = "<title>Hua.Todo - ServedByHost</title>";
public WebRootStaticFileTests()
{
// === 将临时 wwwroot/index.html 写入默认 WebRoot(无需改 WebRoot,避免 WebApplicationBuilder.WebHost.UseWebRoot 抛 NotSupportedException)===
// WebApplicationBuilder 启动后默认 WebRoot = AppContext.BaseDirectory/wwwroot(即 bin/Debug/net10.0/wwwroot)
var baseDir = AppContext.BaseDirectory;
_webRootDir = Path.Combine(baseDir, "wwwroot");
Directory.CreateDirectory(_webRootDir);
File.WriteAllText(
Path.Combine(_webRootDir, "index.html"),
$"<!doctype html><html><head>{SpaTitle}</head><body>{SpaMountPoint}</body></html>");
// === 内存 SQLite(与既有集成测试同模式) ===
_connection = new SqliteConnection("Data Source=WebRootStaticFileTests;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=WebRootStaticFileTests;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();
}
// === GREEN:按 Program.cs 硬约束顺序启用静态文件中间件 + SPA fallback ===
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseDynamicApi();
// SPA fallback(在 UseDynamicApi 之后,不覆盖 /api/*)
app.MapFallbackToFile("index.html");
app.StartAsync().GetAwaiter().GetResult();
return app;
}
[Fact]
public async Task Root_Get_ReturnsIndexHtml_WithAppMountDiv()
{
// Given: Host 启动且 WebRoot/wwwroot 有 index.html
// When : HTTP GET /
// Then : 200 + text/html + body 含 <div id="app"> 挂载点
using var response = await _client.GetAsync("/");
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(SpaMountPoint, body, StringComparison.Ordinal);
Assert.Contains(SpaTitle, body, StringComparison.Ordinal);
}
[Fact]
public async Task SpaRoute_Settings_Get_FallsBackToIndexHtml_Not404()
{
// Given: 同上
// When : HTTP GET /settings(前端内部路由)
// Then : 200 + body 是 index.html(注意:404 是 RED 时的现状,也是本测试在 RED 阶段失败的原因)
using var response = await _client.GetAsync("/settings");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains(SpaMountPoint, body, StringComparison.Ordinal);
}
[Fact]
public async Task ApiDynamicApiRoute_PostProbe_NotOverriddenByFallback()
{
// Given: 同上
// When : HTTP POST /api/cloud-sync/probe(匿名 DynamicApi 端点)
// Then : 返回 JSON(body 含 isReachable 字段),不是 index.html
// 不要求 isReachable=true,因为 test 里 Host 没监听 5173,必 false;也可能 503,但不是 HTML。
using var response = await _client.PostAsJsonAsync("/api/cloud-sync/probe",
new ProbeRequest { TargetUrl = "http://localhost:5173" });
// 状态码不是 404 / 不返回 fallback 的 HTML:
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain(SpaMountPoint, body, StringComparison.Ordinal);
// 至少含 DynamicApi 的 JSON 特征:isReachable 字段
Assert.Contains("isReachable", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SwaggerUi_Get_Returns200_NotOverriddenByFallback()
{
// Given: 开发环境,Swagger 启用
// When : HTTP GET /swagger/index.html
// Then : 200,body 含 Swagger UI 的 "Swagger UI" 或 "swagger-ui",不是前端 index.html
using var response = await _client.GetAsync("/swagger/index.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("swagger", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(SpaMountPoint, body, StringComparison.Ordinal);
}
public void Dispose()
{
_client.Dispose();
try { _app.StopAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
try { _app.DisposeAsync().GetAwaiter().GetResult(); } catch { /* 忽略 */ }
_connection.Dispose();
// 清理临时写入的 index.html(只删我们自己写的,不删目录里的其他可能文件)
try
{
var idx = Path.Combine(_webRootDir, "index.html");
if (File.Exists(idx)) File.Delete(idx);
}
catch { /* 忽略临时文件清理失败 */ }
}
}
@@ -0,0 +1,255 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.DataSeeder;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests.DataSeederTests;
/// <summary>
/// <see cref="DataSeeder"/> 验收测试。
/// 对应研发工单 v1.2.0 工单 10 的验收标准(Given-When-Then),
/// 详见 docs/project/研发工单-v1.2.0/10-数据库初始化默认管理员.md 第五章。
/// </summary>
public sealed class DataSeederTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<TodoDbContext> _options;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
public DataSeederTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
_passwordHasher = new PasswordHasher<UserEntity>();
}
public void Dispose()
{
_connection.Dispose();
}
/// <summary>
/// 创建并迁移数据库上下文(每个测试独立实例,避免数据互相污染)。
/// </summary>
private TodoDbContext CreateContext()
{
var context = new TodoDbContext(_options);
context.Database.Migrate();
return context;
}
/// <summary>
/// 创建使用默认配置的 DataSeeder。
/// </summary>
private static DataSeeder CreateSeeder(TodoDbContext db, IPasswordHasher<UserEntity> hasher, AdminBootstrapOptions? options = null)
=> new(db, hasher, options ?? new AdminBootstrapOptions());
// ========== 5.1 主流程 ==========
/// <summary>
/// Given 全新空数据库(Migrate 完成,Users 表无任何云用户)
/// When 调用 IDataSeeder.SeedAsync()
/// Then Users 表新增一条记录,Role="admin",UserName 取配置值,MustChangePassword=true
/// And SecurityPolicies 表新增对应记录(AllowPersist=true, AllowSync=true)
/// And AuditLogs 表新增一条 EventType="SeedBootstrap" 记录
/// </summary>
[Fact]
public async Task SeedAsync_EmptyDatabase_CreatesDefaultAdminWithPolicyAndAuditLog()
{
using var db = CreateContext();
var options = new AdminBootstrapOptions { UserName = "admin", Password = "Admin@123", Enabled = true };
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var admin = await db.Users.SingleOrDefaultAsync(u => u.Role == "admin");
Assert.NotNull(admin);
Assert.Equal("admin", admin!.UserName);
Assert.True(admin.MustChangePassword);
var policy = await db.SecurityPolicies.SingleOrDefaultAsync(p => p.UserId == admin.Id);
Assert.NotNull(policy);
Assert.True(policy!.AllowPersist);
Assert.True(policy.AllowSync);
var audit = await db.AuditLogs.SingleOrDefaultAsync(a => a.EventType == "SeedBootstrap");
Assert.NotNull(audit);
Assert.True(audit!.IsSuccess);
}
// ========== 5.2 幂等性 ==========
/// <summary>
/// Given 数据库已存在 Role="admin" 的云用户
/// When 调用 SeedAsync()
/// Then Users 表不新增记录
/// And AuditLogs 表不新增 SeedBootstrap 记录
/// </summary>
[Fact]
public async Task SeedAsync_ExistingCloudUser_DoesNotCreateDuplicate()
{
using var db = CreateContext();
var existingAdmin = await SeedExistingAdminAsync(db, "admin");
var seeder = CreateSeeder(db, _passwordHasher);
await seeder.SeedAsync();
var admins = await db.Users.Where(u => u.Role == "admin").ToListAsync();
Assert.Single(admins);
Assert.Equal(existingAdmin.Id, admins[0].Id);
var seedAudits = await db.AuditLogs.Where(a => a.EventType == "SeedBootstrap").ToListAsync();
Assert.Empty(seedAudits);
}
// ========== 5.3 配置自定义账号 ==========
/// <summary>
/// Given AdminBootstrapOptions.UserName="root" 且 Password="MyStrong#123"
/// When 调用 SeedAsync()
/// Then 创建的管理员 UserName="root"
/// And 使用该密码可通过 IPasswordHasher.VerifyHashedPassword 校验
/// </summary>
[Fact]
public async Task SeedAsync_CustomOptions_UsesConfiguredCredentials()
{
using var db = CreateContext();
var options = new AdminBootstrapOptions
{
Enabled = true,
UserName = "root",
Password = "MyStrong#123"
};
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var admin = await db.Users.SingleOrDefaultAsync(u => u.UserName == "root");
Assert.NotNull(admin);
Assert.Equal("admin", admin!.Role);
var verify = _passwordHasher.VerifyHashedPassword(admin, admin.PasswordHash, "MyStrong#123");
Assert.Equal(PasswordVerificationResult.Success, verify);
}
// ========== 5.4 配置禁用 ==========
/// <summary>
/// Given AdminBootstrapOptions.Enabled=false
/// When 调用 SeedAsync()
/// Then 不执行任何种子逻辑,直接返回
/// </summary>
[Fact]
public async Task SeedAsync_DisabledOption_DoesNothing()
{
using var db = CreateContext();
var options = new AdminBootstrapOptions { Enabled = false };
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var userCount = await db.Users.CountAsync();
Assert.Equal(0, userCount);
var auditCount = await db.AuditLogs.CountAsync();
Assert.Equal(0, auditCount);
}
// ========== 5.5 密码哈希安全 ==========
/// <summary>
/// Given 执行 SeedAsync() 成功
/// When 查询 Users 表
/// Then PasswordHash 不等于明文密码
/// And PasswordHash 可通过 IPasswordHasher.VerifyHashedPassword(明文) 校验为 Success
/// </summary>
[Fact]
public async Task SeedAsync_PasswordHashed_NotStoredInPlaintext()
{
using var db = CreateContext();
const string plainPassword = "Admin@123";
var options = new AdminBootstrapOptions { Enabled = true, UserName = "admin", Password = plainPassword };
var seeder = CreateSeeder(db, _passwordHasher, options);
await seeder.SeedAsync();
var admin = await db.Users.SingleAsync(u => u.Role == "admin");
Assert.NotEqual(plainPassword, admin.PasswordHash);
Assert.NotEmpty(admin.PasswordHash);
var verify = _passwordHasher.VerifyHashedPassword(admin, admin.PasswordHash, plainPassword);
Assert.Equal(PasswordVerificationResult.Success, verify);
}
// ========== 5.6 既有 local 用户不影响初始化 ==========
/// <summary>
/// Given Users 表仅存在 Role="local" 的本地用户(嵌入式遗留数据)
/// When 调用 SeedAsync()
/// Then 仍然创建默认管理员(因为幂等条件是 Role != "local" 的云用户)
/// </summary>
[Fact]
public async Task SeedAsync_OnlyLocalUserExists_StillCreatesAdmin()
{
using var db = CreateContext();
await SeedLocalUserAsync(db, "local");
var seeder = CreateSeeder(db, _passwordHasher);
await seeder.SeedAsync();
var localUser = await db.Users.SingleAsync(u => u.Role == "local");
var admin = await db.Users.SingleOrDefaultAsync(u => u.Role == "admin");
Assert.NotNull(admin);
Assert.NotEqual(localUser.Id, admin!.Id);
Assert.Equal(2, await db.Users.CountAsync());
}
// ========== 辅助方法 ==========
/// <summary>
/// 写入一个已存在的云管理员(模拟"系统已初始化"场景)。
/// </summary>
private async Task<UserEntity> SeedExistingAdminAsync(TodoDbContext db, string userName)
{
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = userName,
Role = "admin",
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow
};
user.PasswordHash = _passwordHasher.HashPassword(user, "Existing#123");
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
/// <summary>
/// 写入一个本地用户(Role="local",嵌入式模式遗留数据)。
/// </summary>
private async Task SeedLocalUserAsync(TodoDbContext db, string userName)
{
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = userName,
Role = "local",
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow
};
user.PasswordHash = _passwordHasher.HashPassword(user, "local-dummy");
db.Users.Add(user);
await db.SaveChangesAsync();
}
}