bag
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user