mi-assessment/server/MiAssessment/tests/MiAssessment.Tests/Services/WechatPayV3SignaturePropertyTests.cs
2026-02-03 14:25:01 +08:00

353 lines
12 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 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
}