This commit is contained in:
wanghu
2026-09-02 09:38:45 +08:00
commit 8774b6cc44
128 changed files with 9382 additions and 0 deletions
@@ -0,0 +1,131 @@
using System.Linq;
using System.Text.Json;
using Xunit;
namespace Yh.Platform.AppAuth.Tests;
/// <summary>
/// AppSignHandler basePath 剥离对拍(验收 P1 修复):
/// 链路 handler(fullUrl) → 剥离 basePath → SignUtil(剥离后 canonicalTarget) → 向量基线(Java 口径)。
/// 证明:经 handler 发出的签名 = Java 客户端对同一 URL 的签名(同 ts 时逐字节一致)。
/// </summary>
public class AppSignHandlerTests
{
private sealed class CaptureHandler : HttpMessageHandler
{
public HttpRequestMessage? Last { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Last = request;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
private const string AppKey = "100476";
private const string Secret = "app-secret-100476-yunhai";
private static async Task<HttpRequestMessage> SendThroughHandlerAsync(AppSignHandler handler, HttpMethod method, string url)
{
using var client = new HttpClient(handler);
var request = new HttpRequestMessage(method, url);
await client.SendAsync(request);
// handler 已把三头写入同一个 request 实例;CaptureHandler 捕获的是该实例
return request;
}
/// <summary>读取请求级向量:固定 ts 后,经 handler 对 fullUrl 的签名须与向量基线(Java 口径)逐字节一致。</summary>
public static IEnumerable<object[]> RequestLevelCases()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json");
var root = JsonDocument.Parse(File.ReadAllText(path)).RootElement;
foreach (var c in root.GetProperty("cases").EnumerateArray())
{
if (c.TryGetProperty("fullUrl", out _))
{
yield return new object[] { c.Clone() };
}
}
}
[Theory]
[MemberData(nameof(RequestLevelCases))]
public async Task Handler对fullUrl签名_与Java基线逐字节一致(JsonElement c)
{
var name = c.GetProperty("name").GetString();
var method = new HttpMethod(c.GetProperty("method").GetString()!);
var fullUrl = c.GetProperty("fullUrl").GetString()!;
var basePath = c.GetProperty("basePath").GetString()!;
var ts = c.GetProperty("ts").GetString()!;
var secret = c.GetProperty("secret").GetString()!;
var expected = c.GetProperty("expectedRequestSignBase64").GetString()!;
// 固定 ts = 向量 ts(SignUtil.NowMillis 为 handler 与 SignUtil 共用的时钟源)
var original = SignUtil.NowMillis;
SignUtil.NowMillis = () => long.Parse(ts);
try
{
var handler = new AppSignHandler(AppKey, secret, basePath, new CaptureHandler());
var sent = await SendThroughHandlerAsync(handler, method, fullUrl);
Assert.Equal(AppKey, sent.Headers.GetValues(AppSignHandler.HeaderAppKey).First());
Assert.Equal(ts, sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First());
// 同 URL、同 ts 下与 Java 客户端(AppSignInterceptor 剥 basePath 后)签名逐字节一致
Assert.Equal(expected, sent.Headers.GetValues(AppSignHandler.HeaderSignature).First());
}
finally
{
SignUtil.NowMillis = original;
}
}
[Fact]
public async Task Handler剥离basePath_并排序query后签名()
{
var handler = new AppSignHandler(AppKey, Secret, "/userbase", new CaptureHandler());
var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get,
"http://base.bestsch.com/userbase/oapi/user/byId?b=2&a=1");
var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First();
var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First();
// 剥离 /userbase + query 排序:canonical = /oapi/user/byId?a=1&b=2
Assert.Equal(SignUtil.SignRequest("GET", "/oapi/user/byId?a=1&b=2", ts, Secret), signature);
}
[Fact]
public async Task Handler未配置basePath_不剥离原样签名()
{
var handler = new AppSignHandler(AppKey, Secret, null, new CaptureHandler());
var sent = await SendThroughHandlerAsync(handler, HttpMethod.Get,
"http://base.bestsch.com/userbase/oapi/user/byId");
var ts = sent.Headers.GetValues(AppSignHandler.HeaderTimestamp).First();
var signature = sent.Headers.GetValues(AppSignHandler.HeaderSignature).First();
Assert.Equal(SignUtil.SignRequest("GET", "/userbase/oapi/user/byId", ts, Secret), signature);
}
[Theory]
[InlineData("/userbase/oapi/u", "/userbase", "/oapi/u")]
[InlineData("/userbase", "/userbase", "/")]
[InlineData("/userbase/", "/userbase", "/")]
[InlineData("/userbase/oapi/u", "/userbase/", "/oapi/u")] // basePath 带尾斜杠归一化
[InlineData("/userbase2/x", "/userbase", "/userbase2/x")] // 前缀不完整匹配不剥离(对齐 Java)
[InlineData("/oapi/u", "/userbase", "/oapi/u")] // 无前缀不剥离
[InlineData("/userbase/oapi/u", null, "/userbase/oapi/u")] // basePath 空 = 挂根路径
public void StripBasePath_边界与Java一致(string path, string? basePath, string expected)
{
Assert.Equal(expected, SignCanonical.StripBasePath(path, basePath));
}
[Fact]
public void BuildTarget_带basePath_剥离并排序query()
{
Assert.Equal("/oapi/user/byId?a=1&b=2",
SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1"), "/userbase"));
// 不传 basePath 保持既有行为(不剥离,query 仍排序)
Assert.Equal("/userbase/oapi/user/byId?a=1&b=2",
SignCanonical.BuildTarget(new Uri("http://h/userbase/oapi/user/byId?b=2&a=1")));
}
}
@@ -0,0 +1,10 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Net;
global using System.Net.Http;
global using System.Text;
global using System.Threading;
global using System.Threading.Tasks;
global using Yh.Platform.Abstractions;
global using Yh.Platform.AppAuth;
@@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Xunit;
using Yh.Platform.AppAuth;
namespace Yh.Platform.AppAuth.Tests;
public class SignCanonicalTests
{
[Theory]
[InlineData("b=2&a=1", "a=1&b=2")]
[InlineData("a=1", "a=1")]
[InlineData("", "")]
[InlineData("groupId=9&withSub=true", "groupId=9&withSub=true")]
public void SortRawQuery_键值整串排序(string raw, string expected)
{
Assert.Equal(expected, SignCanonical.SortRawQuery(raw));
}
[Fact]
public void SortRawQuery_URL解码后排序()
{
// %20 与 + 均解码为空格,解码后按 "k=v" 整串排序
Assert.Equal("a b=1&a b=2", SignCanonical.SortRawQuery("a%20b=1&a+b=2"));
}
[Fact]
public void BuildTarget_无query只取path()
{
Assert.Equal("/userbase/oapi/user/byId", SignCanonical.BuildTarget(new Uri("http://host:8080/userbase/oapi/user/byId")));
Assert.Equal("/userbase/oapi/user/byId?id=123", SignCanonical.BuildTarget(new Uri("http://host:8080/userbase/oapi/user/byId?id=123")));
}
[Fact]
public void BuildTarget_query乱序归一化()
{
Assert.Equal("/p?a=1&b=2", SignCanonical.BuildTarget(new Uri("http://h/p?b=2&a=1")));
}
[Fact]
public async Task MemoryReplayGuard_同三元组只放行一次()
{
var guard = new MemoryReplayGuard();
Assert.True(await guard.TryBeginAsync("100476", "1700000000000", "n1"));
Assert.False(await guard.TryBeginAsync("100476", "1700000000000", "n1"));
Assert.True(await guard.TryBeginAsync("100476", "1700000000000", "n2"));
Assert.True(await guard.TryBeginAsync("100477", "1700000000000", "n1"));
}
}
@@ -0,0 +1,98 @@
using System.IO;
using System.Linq;
using Xunit;
using Yh.Platform.AppAuth;
namespace Yh.Platform.AppAuth.Tests;
/// <summary>签名向量逐条对拍(基线由独立实现产出,见 contract/fixtures/sign-vectors.json)。</summary>
public class SignVectorTests
{
public static System.Collections.Generic.IEnumerable<object[]> VectorCases()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json");
var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement;
foreach (var c in root.GetProperty("cases").EnumerateArray())
{
yield return new object[] { c.Clone() };
}
}
[Theory]
[MemberData(nameof(VectorCases))]
public void 向量对拍_逐字节一致(System.Text.Json.JsonElement c)
{
var name = c.GetProperty("name").GetString();
var ts = c.GetProperty("ts").GetString()!;
var secret = c.GetProperty("secret").GetString()!;
if (c.TryGetProperty("expectedDataSignHex", out var hex))
{
var data = c.GetProperty("data").GetString()!;
Assert.True(SignUtil.Sign(data, ts, secret) == hex.GetString(), "数据级向量不一致: " + name);
}
else
{
var method = c.GetProperty("method").GetString()!;
var target = c.GetProperty("canonicalTarget").GetString()!;
var expected = c.GetProperty("expectedRequestSignBase64").GetString()!;
Assert.True(SignUtil.SignRequest(method, target, ts, secret) == expected, "请求级向量不一致: " + name);
}
}
[Fact]
public void 数据级验签_忽略大小写()
{
var secret = "0123456789abcdef";
var sign = SignUtil.Sign("hello", "1700000000000", secret);
Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToUpperInvariant()));
Assert.True(SignUtil.Verify("hello", "1700000000000", secret, sign.ToLowerInvariant()));
Assert.False(SignUtil.Verify("hello2", "1700000000000", secret, sign));
}
[Fact]
public void 请求级验签_精确比对()
{
var secret = "0123456789abcdef";
var sign = SignUtil.SignRequest("GET", "/p?a=1", "1700000000000", secret);
Assert.True(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign));
Assert.False(SignUtil.VerifyRequest("GET", "/p?a=1", "1700000000000", secret, sign.ToLowerInvariant()));
Assert.False(SignUtil.VerifyRequest("POST", "/p?a=1", "1700000000000", secret, sign));
}
[Fact]
public void 密钥过短_抛参数异常()
{
Assert.Throws<ArgumentException>(() => SignUtil.Sign("x", "1700000000000", "short"));
// 16 位恰为最低边界,合法不抛
Assert.Equal(SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456"),
SignUtil.SignRequest("GET", "/p", "1700000000000", "1234567890123456"));
}
[Fact]
public void 过期时间戳_拒绝_双向偏差()
{
var secret = "0123456789abcdef";
var data = "x";
SignUtil.NowMillis = static () => 1700000000000L;
try
{
var sign = SignUtil.Sign(data, "1700000000000", secret);
Assert.True(SignUtil.VerifyWithExpire(data, "1700000000000", secret, sign)); // 恰好等于 now
Assert.False(SignUtil.VerifyWithExpire(data, "1699999699001", secret, sign)); // 5min-999ms 前
Assert.False(SignUtil.VerifyWithExpire(data, "1700000301000", secret, sign)); // 5min+1s 后
}
finally
{
SignUtil.NowMillis = static () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
}
[Fact]
public void 向量文件至少十组()
{
var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "sign-vectors.json");
var root = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)).RootElement;
Assert.True(root.GetProperty("cases").GetArrayLength() >= 10);
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Yh.Platform.AppAuth\Yh.Platform.AppAuth.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\contract\fixtures\**\*.*" CopyToOutputDirectory="PreserveNewest" LinkBase="fixtures" />
</ItemGroup>
</Project>