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; /// /// 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,否则前端拦截器无法判断) /// 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(options => options.UseSqlite(_connection)); builder.Services.AddCloudSyncServer(); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); 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( "Hua.Todo - 前端尚未构建" + "前端尚未构建" + "dotnet build src/Hua.Todo.Host"); }); 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(); } }