353 lines
12 KiB
C#
353 lines
12 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using FsCheck;
|
||
using FsCheck.Xunit;
|
||
using MiAssessment.Core.Interfaces;
|
||
using MiAssessment.Core.Services;
|
||
using MiAssessment.Model.Data;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
using Moq;
|
||
using Xunit;
|
||
|
||
namespace MiAssessment.Tests.Services;
|
||
|
||
/// <summary>
|
||
/// 微信支付 V3 签名属性测试
|
||
/// **Feature: wechat-pay-v3-upgrade**
|
||
/// </summary>
|
||
public class WechatPayV3SignaturePropertyTests
|
||
{
|
||
// 测试用 RSA 密钥对(仅用于测试)
|
||
private static readonly string TestPrivateKey;
|
||
private static readonly string TestPublicKey;
|
||
|
||
static WechatPayV3SignaturePropertyTests()
|
||
{
|
||
// 生成测试用 RSA 密钥对
|
||
using var rsa = RSA.Create(2048);
|
||
TestPrivateKey = ExportPrivateKeyPem(rsa);
|
||
TestPublicKey = ExportPublicKeyPem(rsa);
|
||
}
|
||
|
||
private static string ExportPrivateKeyPem(RSA rsa)
|
||
{
|
||
var privateKeyBytes = rsa.ExportRSAPrivateKey();
|
||
var base64 = Convert.ToBase64String(privateKeyBytes);
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("-----BEGIN RSA PRIVATE KEY-----");
|
||
for (int i = 0; i < base64.Length; i += 64)
|
||
{
|
||
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
|
||
}
|
||
sb.AppendLine("-----END RSA PRIVATE KEY-----");
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static string ExportPublicKeyPem(RSA rsa)
|
||
{
|
||
var publicKeyBytes = rsa.ExportRSAPublicKey();
|
||
var base64 = Convert.ToBase64String(publicKeyBytes);
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("-----BEGIN RSA PUBLIC KEY-----");
|
||
for (int i = 0; i < base64.Length; i += 64)
|
||
{
|
||
sb.AppendLine(base64.Substring(i, Math.Min(64, base64.Length - i)));
|
||
}
|
||
sb.AppendLine("-----END RSA PUBLIC KEY-----");
|
||
return sb.ToString();
|
||
}
|
||
|
||
private IWechatPayV3Service CreateService()
|
||
{
|
||
var options = new DbContextOptionsBuilder<MiAssessmentDbContext>()
|
||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||
.Options;
|
||
var dbContext = new MiAssessmentDbContext(options);
|
||
var httpClient = new HttpClient();
|
||
var logger = Mock.Of<ILogger<WechatPayV3Service>>();
|
||
var configService = Mock.Of<IWechatPayConfigService>();
|
||
|
||
return new WechatPayV3Service(dbContext, httpClient, logger, configService);
|
||
}
|
||
|
||
#region Property 5: V3 请求签名正确性
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// *For any* V3 请求数据,使用相同的私钥和参数生成的签名应该是确定性的
|
||
/// (相同输入产生相同输出)。
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public bool V3Signature_SameInputs_ShouldProduceSameOutput(
|
||
NonEmptyString method,
|
||
NonEmptyString url,
|
||
NonEmptyString body,
|
||
PositiveInt seed)
|
||
{
|
||
var service = CreateService();
|
||
|
||
// 使用固定的时间戳和随机串以确保确定性
|
||
var timestamp = seed.Get.ToString();
|
||
var nonce = $"nonce{seed.Get}";
|
||
|
||
// 清理输入(移除可能导致问题的字符)
|
||
var cleanMethod = method.Get.Replace("\n", "").Replace("\r", "").ToUpper();
|
||
var cleanUrl = "/" + url.Get.Replace("\n", "").Replace("\r", "").TrimStart('/');
|
||
var cleanBody = body.Get.Replace("\n", " ").Replace("\r", "");
|
||
|
||
// 生成两次签名
|
||
var signature1 = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey);
|
||
var signature2 = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey);
|
||
|
||
// 相同输入应该产生相同输出
|
||
return signature1 == signature2;
|
||
}
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// *For any* V3 请求数据,生成的签名应该是有效的 Base64 字符串。
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public bool V3Signature_ShouldBeValidBase64(
|
||
NonEmptyString method,
|
||
NonEmptyString url,
|
||
NonEmptyString body,
|
||
PositiveInt seed)
|
||
{
|
||
var service = CreateService();
|
||
|
||
var timestamp = seed.Get.ToString();
|
||
var nonce = $"nonce{seed.Get}";
|
||
|
||
var cleanMethod = method.Get.Replace("\n", "").Replace("\r", "").ToUpper();
|
||
var cleanUrl = "/" + url.Get.Replace("\n", "").Replace("\r", "").TrimStart('/');
|
||
var cleanBody = body.Get.Replace("\n", " ").Replace("\r", "");
|
||
|
||
var signature = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey);
|
||
|
||
// 验证是有效的 Base64 字符串
|
||
try
|
||
{
|
||
var bytes = Convert.FromBase64String(signature);
|
||
return bytes.Length > 0;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// *For any* V3 请求数据,不同的输入应该产生不同的签名。
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public bool V3Signature_DifferentInputs_ShouldProduceDifferentOutput(
|
||
NonEmptyString body1,
|
||
NonEmptyString body2,
|
||
PositiveInt seed)
|
||
{
|
||
// 如果两个 body 相同,跳过测试
|
||
if (body1.Get == body2.Get) return true;
|
||
|
||
var service = CreateService();
|
||
|
||
var timestamp = seed.Get.ToString();
|
||
var nonce = $"nonce{seed.Get}";
|
||
var method = "POST";
|
||
var url = "/v3/pay/transactions/jsapi";
|
||
|
||
var cleanBody1 = body1.Get.Replace("\n", " ").Replace("\r", "");
|
||
var cleanBody2 = body2.Get.Replace("\n", " ").Replace("\r", "");
|
||
|
||
var signature1 = service.GenerateSignature(method, url, timestamp, nonce, cleanBody1, TestPrivateKey);
|
||
var signature2 = service.GenerateSignature(method, url, timestamp, nonce, cleanBody2, TestPrivateKey);
|
||
|
||
// 不同输入应该产生不同输出
|
||
return signature1 != signature2;
|
||
}
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// *For any* V3 请求数据,签名应该可以使用对应的公钥验证。
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public bool V3Signature_ShouldBeVerifiableWithPublicKey(
|
||
NonEmptyString method,
|
||
NonEmptyString url,
|
||
NonEmptyString body,
|
||
PositiveInt seed)
|
||
{
|
||
var service = CreateService();
|
||
|
||
var timestamp = seed.Get.ToString();
|
||
var nonce = $"nonce{seed.Get}";
|
||
|
||
var cleanMethod = method.Get.Replace("\n", "").Replace("\r", "").ToUpper();
|
||
var cleanUrl = "/" + url.Get.Replace("\n", "").Replace("\r", "").TrimStart('/');
|
||
var cleanBody = body.Get.Replace("\n", " ").Replace("\r", "");
|
||
|
||
// 生成签名
|
||
var signature = service.GenerateSignature(cleanMethod, cleanUrl, timestamp, nonce, cleanBody, TestPrivateKey);
|
||
|
||
// 构建签名字符串(与 GenerateSignature 方法中的格式一致)
|
||
var signatureString = $"{cleanMethod}\n{cleanUrl}\n{timestamp}\n{nonce}\n{cleanBody}\n";
|
||
|
||
// 使用公钥验证签名
|
||
try
|
||
{
|
||
using var rsa = RSA.Create();
|
||
rsa.ImportFromPem(TestPublicKey);
|
||
|
||
var signatureBytes = Convert.FromBase64String(signature);
|
||
return rsa.VerifyData(
|
||
Encoding.UTF8.GetBytes(signatureString),
|
||
signatureBytes,
|
||
HashAlgorithmName.SHA256,
|
||
RSASignaturePadding.Pkcs1);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 辅助方法测试
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// GenerateNonceStr 应该生成指定长度的随机字符串。
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public bool GenerateNonceStr_ShouldHaveCorrectLength(PositiveInt length)
|
||
{
|
||
var service = CreateService();
|
||
var actualLength = Math.Min(Math.Max(length.Get % 64, 1), 64); // 限制在 1-64 之间
|
||
|
||
var nonceStr = service.GenerateNonceStr(actualLength);
|
||
|
||
return nonceStr.Length == actualLength;
|
||
}
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// GenerateNonceStr 应该只包含字母和数字。
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Property(MaxTest = 100)]
|
||
public bool GenerateNonceStr_ShouldContainOnlyAlphanumeric(PositiveInt seed)
|
||
{
|
||
var service = CreateService();
|
||
var nonceStr = service.GenerateNonceStr(32);
|
||
|
||
return nonceStr.All(c => char.IsLetterOrDigit(c));
|
||
}
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// GetTimestamp 应该返回有效的 Unix 时间戳。
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Fact]
|
||
public void GetTimestamp_ShouldReturnValidUnixTimestamp()
|
||
{
|
||
var service = CreateService();
|
||
var timestamp = service.GetTimestamp();
|
||
|
||
// 应该是数字字符串
|
||
Assert.True(long.TryParse(timestamp, out var timestampValue));
|
||
|
||
// 应该是合理的时间戳(2020年之后,2100年之前)
|
||
var minTimestamp = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeSeconds();
|
||
var maxTimestamp = new DateTimeOffset(2100, 1, 1, 0, 0, 0, TimeSpan.Zero).ToUnixTimeSeconds();
|
||
|
||
Assert.True(timestampValue >= minTimestamp && timestampValue <= maxTimestamp);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region 签名格式验证
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// 签名字符串格式应该符合微信 V3 规范:HTTP方法\nURL\n时间戳\n随机串\n请求体\n
|
||
/// **Validates: Requirements 3.3**
|
||
/// </summary>
|
||
[Fact]
|
||
public void V3Signature_Format_ShouldMatchWechatSpec()
|
||
{
|
||
var service = CreateService();
|
||
|
||
var method = "POST";
|
||
var url = "/v3/pay/transactions/jsapi";
|
||
var timestamp = "1609459200";
|
||
var nonce = "5K8264ILTKCH16CQ2502SI8ZNMTM67VS";
|
||
var body = "{\"appid\":\"wx1234567890\",\"mchid\":\"1234567890\"}";
|
||
|
||
// 生成签名
|
||
var signature = service.GenerateSignature(method, url, timestamp, nonce, body, TestPrivateKey);
|
||
|
||
// 验证签名不为空
|
||
Assert.False(string.IsNullOrEmpty(signature));
|
||
|
||
// 验证是有效的 Base64
|
||
var signatureBytes = Convert.FromBase64String(signature);
|
||
Assert.True(signatureBytes.Length > 0);
|
||
|
||
// 验证签名长度(RSA-2048 签名应该是 256 字节)
|
||
Assert.Equal(256, signatureBytes.Length);
|
||
}
|
||
|
||
/// <summary>
|
||
/// **Feature: wechat-pay-v3-upgrade, Property 5: V3 请求签名正确性**
|
||
/// 小程序支付签名格式应该符合微信规范:appId\n时间戳\n随机串\nprepay_id=xxx\n
|
||
/// **Validates: Requirements 3.4**
|
||
/// </summary>
|
||
[Fact]
|
||
public void GeneratePaySign_Format_ShouldMatchWechatSpec()
|
||
{
|
||
var service = CreateService();
|
||
|
||
var appId = "wx1234567890";
|
||
var timestamp = "1609459200";
|
||
var nonceStr = "5K8264ILTKCH16CQ2502SI8ZNMTM67VS";
|
||
var prepayId = "wx201410272009395522657a690389285100";
|
||
|
||
// 生成支付签名
|
||
var paySign = service.GeneratePaySign(appId, timestamp, nonceStr, prepayId, TestPrivateKey);
|
||
|
||
// 验证签名不为空
|
||
Assert.False(string.IsNullOrEmpty(paySign));
|
||
|
||
// 验证是有效的 Base64
|
||
var signatureBytes = Convert.FromBase64String(paySign);
|
||
Assert.True(signatureBytes.Length > 0);
|
||
|
||
// 验证签名长度(RSA-2048 签名应该是 256 字节)
|
||
Assert.Equal(256, signatureBytes.Length);
|
||
|
||
// 验证签名可以用公钥验证
|
||
var signatureString = $"{appId}\n{timestamp}\n{nonceStr}\nprepay_id={prepayId}\n";
|
||
using var rsa = RSA.Create();
|
||
rsa.ImportFromPem(TestPublicKey);
|
||
|
||
var isValid = rsa.VerifyData(
|
||
Encoding.UTF8.GetBytes(signatureString),
|
||
signatureBytes,
|
||
HashAlgorithmName.SHA256,
|
||
RSASignaturePadding.Pkcs1);
|
||
|
||
Assert.True(isValid);
|
||
}
|
||
|
||
#endregion
|
||
}
|