using System.Linq; using System.Text.Json; using Xunit; namespace Yh.Platform.AppAuth.Tests; /// /// AppSignHandler basePath 剥离对拍(验收 P1 修复): /// 链路 handler(fullUrl) → 剥离 basePath → SignUtil(剥离后 canonicalTarget) → 向量基线(Java 口径)。 /// 证明:经 handler 发出的签名 = Java 客户端对同一 URL 的签名(同 ts 时逐字节一致)。 /// public class AppSignHandlerTests { private sealed class CaptureHandler : HttpMessageHandler { public HttpRequestMessage? Last { get; private set; } protected override Task 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 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; } /// 读取请求级向量:固定 ts 后,经 handler 对 fullUrl 的签名须与向量基线(Java 口径)逐字节一致。 public static IEnumerable 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"))); } }