273 lines
10 KiB
C#
273 lines
10 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 AuthServiceBindMobilePropertyTests
|
|
{
|
|
private HoneyBoxDbContext CreateInMemoryDbContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
|
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning))
|
|
.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>>();
|
|
|
|
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 12: 手机号绑定验证码验证
|
|
/// For any mobile binding request:
|
|
/// - If verification code is correct, binding should succeed
|
|
/// - If verification code is incorrect, binding should fail with "验证码错误"
|
|
/// Validates: Requirements 5.1, 5.5
|
|
/// Feature: user-auth-migration, Property 12: 手机号绑定验证码验证
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> BindMobileVerificationCodeValidation()
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, _, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var mobile = "138" + Random.Shared.Next(10000000, 99999999).ToString();
|
|
var correctCode = "123456";
|
|
var wrongCode = "654321";
|
|
|
|
// Create a user to bind mobile to
|
|
var user = new User
|
|
{
|
|
OpenId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8),
|
|
Uid = "uid123",
|
|
Nickname = "TestUser",
|
|
HeadImg = "https://example.com/avatar.jpg",
|
|
Status = 1,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
await dbContext.Users.AddAsync(user);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
// 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.BindMobileAsync(user.Id, mobile, correctCode);
|
|
var userAfterBind = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == user.Id);
|
|
var bindSuccess = userAfterBind?.Mobile == mobile;
|
|
|
|
// Test 2: Wrong code should fail
|
|
var dbContext2 = CreateInMemoryDbContext();
|
|
var (authService2, _, redisMock2) = CreateAuthService(dbContext2);
|
|
|
|
var user2 = new User
|
|
{
|
|
OpenId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8),
|
|
Uid = "uid456",
|
|
Nickname = "TestUser2",
|
|
HeadImg = "https://example.com/avatar.jpg",
|
|
Status = 1,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
await dbContext2.Users.AddAsync(user2);
|
|
await dbContext2.SaveChangesAsync();
|
|
|
|
redisMock2.Setup(x => x.GetStringAsync($"sms:code:{mobile}"))
|
|
.ReturnsAsync(correctCode);
|
|
|
|
bool wrongCodeFailed = false;
|
|
try
|
|
{
|
|
await authService2.BindMobileAsync(user2.Id, mobile, wrongCode);
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message == "验证码错误")
|
|
{
|
|
wrongCodeFailed = true;
|
|
}
|
|
|
|
return bindSuccess && wrongCodeFailed;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Property 13: 账户合并正确性
|
|
/// When a mobile number is already bound to another user:
|
|
/// - The current user's openid should be migrated to the mobile user
|
|
/// - The current user record should be deleted
|
|
/// - A new token should be returned for the mobile user
|
|
/// Validates: Requirements 5.2, 5.3
|
|
/// Feature: user-auth-migration, Property 13: 账户合并正确性
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> AccountMergeCorrectness()
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, _, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var mobile = "138" + Random.Shared.Next(10000000, 99999999).ToString();
|
|
var correctCode = "123456";
|
|
var currentUserOpenId = "openid_current_" + Guid.NewGuid().ToString().Substring(0, 8);
|
|
|
|
// Create current user (WeChat user without mobile)
|
|
var currentUser = new User
|
|
{
|
|
OpenId = currentUserOpenId,
|
|
Uid = "uid_current",
|
|
Nickname = "CurrentUser",
|
|
HeadImg = "https://example.com/avatar1.jpg",
|
|
Status = 1,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
await dbContext.Users.AddAsync(currentUser);
|
|
|
|
// Create mobile user (user with mobile already bound)
|
|
var mobileUser = new User
|
|
{
|
|
OpenId = "openid_mobile",
|
|
Mobile = mobile,
|
|
Uid = "uid_mobile",
|
|
Nickname = "MobileUser",
|
|
HeadImg = "https://example.com/avatar2.jpg",
|
|
Status = 1,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
await dbContext.Users.AddAsync(mobileUser);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
var currentUserId = currentUser.Id;
|
|
var mobileUserId = mobileUser.Id;
|
|
|
|
// Setup Redis mock
|
|
redisMock.Setup(x => x.GetStringAsync($"sms:code:{mobile}"))
|
|
.ReturnsAsync(correctCode);
|
|
redisMock.Setup(x => x.DeleteAsync($"sms:code:{mobile}"))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
// Act: Bind mobile (should trigger account merge)
|
|
var result = await authService.BindMobileAsync(currentUserId, mobile, correctCode);
|
|
|
|
// Assert
|
|
// 1. Current user should be deleted
|
|
var currentUserAfter = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == currentUserId);
|
|
var currentUserDeleted = currentUserAfter == null;
|
|
|
|
// 2. Mobile user should have the current user's openid
|
|
var mobileUserAfter = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == mobileUserId);
|
|
var openIdMigrated = mobileUserAfter?.OpenId == currentUserOpenId;
|
|
|
|
// 3. New token should be returned
|
|
var newTokenReturned = !string.IsNullOrWhiteSpace(result.Token);
|
|
|
|
return currentUserDeleted && openIdMigrated && newTokenReturned;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property 14: 直接绑定手机号
|
|
/// When a mobile number is not bound to any other user:
|
|
/// - The current user's mobile should be updated directly
|
|
/// - No token should be returned (no account merge needed)
|
|
/// Validates: Requirements 5.4
|
|
/// Feature: user-auth-migration, Property 14: 直接绑定手机号
|
|
/// </summary>
|
|
[Property(MaxTest = 100)]
|
|
public async Task<bool> DirectMobileBinding()
|
|
{
|
|
var dbContext = CreateInMemoryDbContext();
|
|
var (authService, _, redisMock) = CreateAuthService(dbContext);
|
|
|
|
var mobile = "138" + Random.Shared.Next(10000000, 99999999).ToString();
|
|
var correctCode = "123456";
|
|
|
|
// Create a user without mobile
|
|
var user = new User
|
|
{
|
|
OpenId = "openid_" + Guid.NewGuid().ToString().Substring(0, 8),
|
|
Uid = "uid123",
|
|
Nickname = "TestUser",
|
|
HeadImg = "https://example.com/avatar.jpg",
|
|
Status = 1,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow
|
|
};
|
|
await dbContext.Users.AddAsync(user);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
// Setup Redis mock
|
|
redisMock.Setup(x => x.GetStringAsync($"sms:code:{mobile}"))
|
|
.ReturnsAsync(correctCode);
|
|
redisMock.Setup(x => x.DeleteAsync($"sms:code:{mobile}"))
|
|
.Returns(Task.CompletedTask);
|
|
|
|
// Act: Bind mobile (should be direct binding)
|
|
var result = await authService.BindMobileAsync(user.Id, mobile, correctCode);
|
|
|
|
// Assert
|
|
// 1. User's mobile should be updated
|
|
var userAfter = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == user.Id);
|
|
var mobileUpdated = userAfter?.Mobile == mobile;
|
|
|
|
// 2. No token should be returned (no merge)
|
|
var noTokenReturned = string.IsNullOrWhiteSpace(result.Token);
|
|
|
|
// 3. User count should remain the same
|
|
var userCount = await dbContext.Users.CountAsync();
|
|
var userCountCorrect = userCount == 1;
|
|
|
|
return mobileUpdated && noTokenReturned && userCountCorrect;
|
|
}
|
|
}
|