3213d4c718
- 重构架构:抽离AddCloudSyncServer到扩展类,补全中间层文档对齐 - 修复根路径404与鉴权跳转:新增中间件处理浏览器请求302跳转 - 实现种子数据初始化:添加默认管理员账号自动创建逻辑 - 优化前端代理与构建:新增Vite代理配置与自动构建Target - 补全测试与文档:新增集成测试,更新开发与部署文档
256 lines
8.8 KiB
C#
256 lines
8.8 KiB
C#
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();
|
||
}
|
||
}
|