268 lines
11 KiB
C#
268 lines
11 KiB
C#
using FsCheck;
|
|
using FsCheck.Xunit;
|
|
using HoneyBox.Core.Interfaces;
|
|
using HoneyBox.Core.Services;
|
|
using HoneyBox.Model.Data;
|
|
using HoneyBox.Model.Entities;
|
|
using HoneyBox.Model.Models.Auth;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace HoneyBox.Tests.Services;
|
|
|
|
/// <summary>
|
|
/// AuthService属性测试
|
|
/// 测试认证服务的核心属性
|
|
/// </summary>
|
|
public class AuthServicePropertyTests
|
|
{
|
|
private HoneyBoxDbContext CreateInMemoryDbContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
|
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
return new HoneyBoxDbContext(options);
|
|
}
|
|
|
|
private (AuthService authService, Mock<IWechatService> wechatMock, Mock<IRedisService> redisMock) CreateAuthService(HoneyBoxDbContext dbContext)
|
|
{
|
|
var mockUserLogger = new Mock<ILogger<UserService>>();
|
|
var userService = new UserService(dbContext, mockUserLogger.Object);
|
|
|
|
var jwtSettings = new JwtSettings
|
|
{
|
|
Secret = "your-secret-key-must-be-at-least-32-characters-long-for-hs256",
|
|
Issuer = "HoneyBox",
|
|
Audience = "HoneyBoxUsers",
|
|
ExpirationMinutes = 1440,
|
|
RefreshTokenExpirationDays = 7
|
|
};
|
|
var mockJwtLogger = new Mock<ILogger<JwtService>>();
|
|
var jwtService = new JwtService(jwtSettings, mockJwtLogger.Object);
|
|
|
|
var mockWechatService = new Mock<IWechatService>();
|
|
var mockIpLocationService = new Mock<IIpLocationService>();
|
|
var mockRedisService = new Mock<IRedisService>();
|
|
var mockAuthLogger = new Mock<ILogger<AuthService>>();
|
|
|
|
// Default IP location result
|
|
mockIpLocationService.Setup(x => x.GetLocationAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(new IpLocationResult { Success = true, Province = "北京", City = "北京", Adcode = "110000" });
|
|
|
|
var authService = new AuthService(
|
|
dbContext,
|
|
userService,
|
|
jwtService,
|
|
mockWechatService.Object,
|
|
mockIpLocationService.Object,
|
|
mockRedisService.Object,
|
|
jwtSettings,
|
|
mockAuthLogger.Object);
|
|
|
|
return (authService, mockWechatService, mockRedisService);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Property 1: 微信登录用户查找优先级
|
|
/// For any login request with unionid, the system should prioritize finding user by unionid first,
|
|
/// then by openid if unionid lookup fails.
|
|
/// Validates: Requirements 1.2
|
|
/// Feature: user-auth-migration, Property 1: 微信登录用户查找优先级
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> WechatLoginPrioritizesUnionIdLookup()
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, wechatMock, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var openId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8);
|
|
var unionId = "unionid_" + Guid.NewGuid().ToString().Substring(0, 8);
|
|
|
|
// Setup: Create a user with unionid
|
|
var existingUser = new User
|
|
{
|
|
OpenId = "different_openid",
|
|
UnionId = unionId,
|
|
Uid = "uid123",
|
|
Nickname = "ExistingUser",
|
|
HeadImg = "https://example.com/avatar.jpg",
|
|
Status = 1,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
await dbContext.Users.AddAsync(existingUser);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
// Mock WeChat API to return the unionid
|
|
wechatMock.Setup(x => x.GetOpenIdAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(new WechatAuthResult { Success = true, OpenId = openId, UnionId = unionId });
|
|
|
|
// Mock Redis to allow login (no debounce)
|
|
redisMock.Setup(x => x.TryAcquireLockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
.ReturnsAsync(true);
|
|
|
|
// Act
|
|
var result = await authService.WechatMiniProgramLoginAsync("test_code", null, null);
|
|
|
|
// Assert: Should find existing user by unionid, not create new one
|
|
var usersCount = await dbContext.Users.CountAsync();
|
|
return result.Success && result.UserId == existingUser.Id && usersCount == 1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property 4: 登录防抖机制
|
|
/// For any user making repeated login requests within 3 seconds,
|
|
/// the second and subsequent requests should be rejected with "请勿频繁登录" error.
|
|
/// Validates: Requirements 1.6, 2.6
|
|
/// Feature: user-auth-migration, Property 4: 登录防抖机制
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> LoginDebounceRejectsRepeatedRequests()
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, wechatMock, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var openId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8);
|
|
|
|
// Mock WeChat API
|
|
wechatMock.Setup(x => x.GetOpenIdAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(new WechatAuthResult { Success = true, OpenId = openId });
|
|
|
|
// First request: Redis lock succeeds
|
|
redisMock.SetupSequence(x => x.TryAcquireLockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
.ReturnsAsync(true) // First request succeeds
|
|
.ReturnsAsync(false); // Second request fails (debounce)
|
|
|
|
// First login should succeed
|
|
var firstResult = await authService.WechatMiniProgramLoginAsync("test_code_1", null, null);
|
|
|
|
// Second login should be rejected due to debounce
|
|
var secondResult = await authService.WechatMiniProgramLoginAsync("test_code_1", null, null);
|
|
|
|
return firstResult.Success &&
|
|
!secondResult.Success &&
|
|
secondResult.ErrorMessage == "请勿频繁登录";
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Property 5: 推荐关系记录
|
|
/// For any new user registration with a valid pid (recommender ID),
|
|
/// the system should correctly save the pid to the user record.
|
|
/// Validates: Requirements 1.8, 2.7
|
|
/// Feature: user-auth-migration, Property 5: 推荐关系记录
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> RecommenderRelationshipIsRecorded(PositiveInt pid)
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, wechatMock, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var openId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8);
|
|
|
|
// Mock WeChat API
|
|
wechatMock.Setup(x => x.GetOpenIdAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(new WechatAuthResult { Success = true, OpenId = openId });
|
|
|
|
// Mock Redis to allow login
|
|
redisMock.Setup(x => x.TryAcquireLockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
.ReturnsAsync(true);
|
|
|
|
// Act: Login with pid
|
|
var result = await authService.WechatMiniProgramLoginAsync("test_code", pid.Item, null);
|
|
|
|
// Assert: User should have the correct pid
|
|
var user = await dbContext.Users.FirstOrDefaultAsync(u => u.OpenId == openId);
|
|
return result.Success && user != null && user.Pid == pid.Item;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property 6: 验证码验证与清理
|
|
/// For any mobile login request:
|
|
/// - If verification code is correct, login should succeed and code should be deleted from Redis
|
|
/// - If verification code is incorrect or expired, login should fail with "验证码错误"
|
|
/// Validates: Requirements 2.1, 2.2
|
|
/// Feature: user-auth-migration, Property 6: 验证码验证与清理
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> VerificationCodeValidationAndCleanup()
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, wechatMock, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var mobile = "138" + Random.Shared.Next(10000000, 99999999).ToString();
|
|
var correctCode = "123456";
|
|
var wrongCode = "654321";
|
|
|
|
// Mock Redis to allow login (no debounce)
|
|
redisMock.Setup(x => x.TryAcquireLockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
.ReturnsAsync(true);
|
|
|
|
// Test 1: Correct code should succeed
|
|
redisMock.Setup(x => x.GetStringAsync($"sms:code:{mobile}"))
|
|
.ReturnsAsync(correctCode);
|
|
redisMock.Setup(x => x.DeleteAsync($"sms:code:{mobile}"))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
var successResult = await authService.MobileLoginAsync(mobile, correctCode, null, null);
|
|
|
|
// Verify DeleteAsync was called (code cleanup)
|
|
redisMock.Verify(x => x.DeleteAsync($"sms:code:{mobile}"), Times.Once);
|
|
|
|
// Test 2: Wrong code should fail
|
|
var dbContext2 = CreateInMemoryDbContext();
|
|
var (authService2, _, redisMock2) = CreateAuthService(dbContext2);
|
|
|
|
redisMock2.Setup(x => x.TryAcquireLockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
.ReturnsAsync(true);
|
|
redisMock2.Setup(x => x.GetStringAsync($"sms:code:{mobile}"))
|
|
.ReturnsAsync(correctCode);
|
|
|
|
var failResult = await authService2.MobileLoginAsync(mobile, wrongCode, null, null);
|
|
|
|
return successResult.Success &&
|
|
!failResult.Success &&
|
|
failResult.ErrorMessage == "验证码错误";
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Property 8: 数据库Token兼容存储
|
|
/// For any successful login request, the system should store account_token in UserAccount table
|
|
/// to maintain compatibility with the legacy system.
|
|
/// Validates: Requirements 3.6
|
|
/// Feature: user-auth-migration, Property 8: 数据库Token兼容存储
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> DatabaseTokenCompatibilityStorage()
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, wechatMock, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var openId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8);
|
|
|
|
// Mock WeChat API
|
|
wechatMock.Setup(x => x.GetOpenIdAsync(It.IsAny<string>()))
|
|
.ReturnsAsync(new WechatAuthResult { Success = true, OpenId = openId });
|
|
|
|
// Mock Redis to allow login
|
|
redisMock.Setup(x => x.TryAcquireLockAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
.ReturnsAsync(true);
|
|
|
|
// Act
|
|
var result = await authService.WechatMiniProgramLoginAsync("test_code", null, null);
|
|
|
|
// Assert: UserAccount should be created with account_token
|
|
var userAccount = await dbContext.UserAccounts.FirstOrDefaultAsync(ua => ua.UserId == result.UserId);
|
|
|
|
return result.Success &&
|
|
userAccount != null &&
|
|
!string.IsNullOrWhiteSpace(userAccount.AccountToken) &&
|
|
!string.IsNullOrWhiteSpace(userAccount.TokenNum);
|
|
}
|
|
}
|