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
@@ -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();
}
}