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

256 lines
8.8 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 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();
}
}