using System.IO.Compression; using System.Net; using System.Net.Http.Headers; using System.Security.Cryptography; using LanDesktopPLONDS.Installer.Services; using LanMountainDesktop.Shared.Contracts.Deployment; using Xunit; namespace LanMountainDesktop.Tests; /// /// 弹性下载器与诊断聚合测试:覆盖 semver 排序、md5 拒绝、sha256 接受、 /// 重试成功、Range 续传、诊断聚合消息等六个维度。 /// public sealed class InstallerDownloadResilienceTests : IDisposable { private readonly string _tempRoot = Path.Combine( AppContext.BaseDirectory, "TestArtifacts", "LanMountainDesktop.Tests", nameof(InstallerDownloadResilienceTests), Guid.NewGuid().ToString("N")); public void Dispose() { if (Directory.Exists(_tempRoot)) { Directory.Delete(_tempRoot, recursive: true); } } #region 1. SemanticVersion 排序(含预发布版本) [Theory] [InlineData("0.8.5-beta.1", "0.8.5", true)] // 预发布 < 正式版 [InlineData("1.0.0-alpha", "1.0.0-beta", true)] // alpha < beta [InlineData("1.0.0-beta", "1.0.0-rc.1", true)] // beta < rc [InlineData("1.0.0-rc.1", "1.0.0", true)] // rc < 正式版 [InlineData("1.0.0-alpha.1", "1.0.0-alpha.2", true)] // 数字排序 [InlineData("1.0.0", "2.0.0", true)] // 主版本排序 [InlineData("1.2.3", "1.2.3", false)] // 相等 (not less) [InlineData("1.0.0-beta.11", "1.0.0-beta.2", false)] // 11 > 2 (not less) public void SemanticVersion_Ordering_Correct(string left, string right, bool leftLessThanRight) { var v1 = SemanticVersion.Parse(left); var v2 = SemanticVersion.Parse(right); if (leftLessThanRight) { Assert.True(v1 < v2, $"Expected {left} < {right}, got CompareTo={v1.CompareTo(v2)}"); } else { Assert.True(v1 >= v2, $"Expected {left} >= {right}, got CompareTo={v1.CompareTo(v2)}"); } } [Fact] public void SemanticVersion_StableVersionAlwaysBeatsPrerelease() { var stable = SemanticVersion.Parse("0.8.5"); var prerelease = SemanticVersion.Parse("0.8.5-beta.1"); Assert.True(stable > prerelease); Assert.True(prerelease < stable); Assert.Equal(1, stable.CompareTo(prerelease)); Assert.Equal(-1, prerelease.CompareTo(stable)); } [Fact] public void SemanticVersion_PrereleaseAccepted() { Assert.True(SemanticVersion.TryParse("0.8.5-beta.1", out var parsed)); Assert.NotNull(parsed); Assert.Equal(0, parsed!.Major); Assert.Equal(8, parsed.Minor); Assert.Equal(5, parsed.Patch); Assert.Equal("beta.1", parsed.Prerelease); } [Fact] public void SemanticVersion_FourPartAccepted() { Assert.True(SemanticVersion.TryParse("1.2.3.4", out var parsed)); Assert.NotNull(parsed); Assert.Equal(4, parsed!.Revision); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("abc")] [InlineData("1")] [InlineData("1.2.3.4.5")] public void SemanticVersion_InvalidFormat_Rejected(string? value) { Assert.False(SemanticVersion.TryParse(value, out _)); } [Fact] public void SemanticVersion_OrderingInFindLatest_SortsCandidatesCorrectly() { // 模拟候选列表,验证排序逻辑与 FindLatestAsync 一致 var versions = new[] { "0.8.5-beta.1", "0.8.5-alpha.1", "0.8.5", "0.8.4" }; var parsed = versions .Select(v => (Original: v, Semver: SemanticVersion.Parse(v))) .OrderByDescending(x => x.Semver) .Select(x => x.Original) .ToArray(); Assert.Equal("0.8.5", parsed[0]); Assert.Equal("0.8.5-beta.1", parsed[1]); Assert.Equal("0.8.5-alpha.1", parsed[2]); Assert.Equal("0.8.4", parsed[3]); } #endregion #region 2. MD5 拒绝 [Fact] public void VerifyPackage_Md5Checksum_RejectsWithChineseError() { var zipPath = CreateTestZip("test-content"); // ParseChecksum 应该拒绝 MD5 — 反射调用抛 TargetInvocationException 包装 var ex = Assert.ThrowsAny(() => InvokeParseChecksum("md5:" + ComputeMd5(zipPath))); var inner = ex is System.Reflection.TargetInvocationException tie ? tie.InnerException! : ex; Assert.IsType(inner); Assert.Contains("MD5", inner.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("不被支持", inner.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public void VerifyPackage_Md5BareHex_RejectsWithChineseError() { // 32 位十六进制 = MD5,应该被拒绝 var md5Hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"; var ex = Assert.ThrowsAny(() => InvokeParseChecksum(md5Hash)); var inner = ex is System.Reflection.TargetInvocationException tie ? tie.InnerException! : ex; Assert.IsType(inner); Assert.Contains("MD5", inner.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("32 位十六进制", inner.Message, StringComparison.OrdinalIgnoreCase); } #endregion #region 3. SHA-256 接受 [Fact] public async Task VerifyPackage_Sha256Checksum_Accepts() { var zipPath = CreateTestZip("test-content-for-sha256"); var sha256Hash = ComputeSha256(zipPath); var manifest = CreateManifestWithChecksum("sha256:" + sha256Hash); // ParseChecksum 应该接受 SHA-256 var (algorithm, hash) = InvokeParseChecksum("sha256:" + sha256Hash); Assert.Equal("sha256", algorithm); Assert.Equal(sha256Hash, hash); } [Fact] public void VerifyPackage_Sha256BareHex_Accepts() { // 64 位十六进制 = SHA-256,应该被接受 var sha256Hash = new string('a', 64); var (algorithm, hash) = InvokeParseChecksum(sha256Hash); Assert.Equal("sha256", algorithm); Assert.Equal(sha256Hash, hash); } #endregion #region 4. 重试成功(2 次失败后成功) [Fact] public async Task DownloadAndPrepare_RetrySucceedsAfterTwoFailures() { var zipPath = CreateTestZip("retry-test-content"); var sha256Hash = ComputeSha256(zipPath); // 前 2 次返回 500,第 3 次返回内容 var handler = new RetryThenSuccessHandler(zipPath, failCount: 2); var client = new InstallerPlondsClient( new HttpClient(handler), Path.Combine(_tempRoot, "staging"), _ => TimeSpan.Zero); // 测试中跳过实际等待 var candidate = CreateCandidate( sha256Hash: sha256Hash, filesZipUrl: "https://test.example.com/Files.zip"); var package = await client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None); Assert.True(File.Exists(package.ZipPath)); Assert.Equal(4, handler.RequestCount); // 2次失败(HEAD+GET) + 2次成功(HEAD+GET) } [Fact] public async Task DownloadAndPrepare_AllRetriesFail_ThrowsAfterMaxAttempts() { var handler = new AlwaysFailHandler(); var client = new InstallerPlondsClient( new HttpClient(handler), Path.Combine(_tempRoot, "staging"), _ => TimeSpan.Zero); var candidate = CreateCandidate( sha256Hash: "0000000000000000000000000000000000000000000000000000000000000000", filesZipUrl: "https://test.example.com/Files.zip"); var ex = await Assert.ThrowsAsync( () => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None)); Assert.NotNull(ex.InnerException); // 1个唯一URL × 3次重试 × 2个请求(HEAD+GET) = 6 Assert.Equal(6, handler.RequestCount); } #endregion #region 5. Range 续传(.partial 文件存在时发送 Range 头) [Fact] public async Task DownloadAndPrepare_ResumeSendsRangeHeader() { var zipPath = CreateTestZip("resume-test-content"); var stagingDir = Path.Combine(_tempRoot, "staging-resume"); var packageDir = Path.Combine(stagingDir, "1.0.0", "s3", "full"); var partialPath = Path.Combine(packageDir, "Files.zip.partial"); // 创建部分下载的 .partial 文件(5 字节) Directory.CreateDirectory(packageDir); var partialContent = new byte[5]; RandomNumberGenerator.Fill(partialContent); await File.WriteAllBytesAsync(partialPath, partialContent); var fullContent = await File.ReadAllBytesAsync(zipPath); var handler = new RangeResumeHandler(fullContent, partialContent.Length); var client = new InstallerPlondsClient( new HttpClient(handler), stagingDir, _ => TimeSpan.Zero); var sha256Hash = ComputeSha256(zipPath); var candidate = CreateCandidate( sha256Hash: sha256Hash, filesZipUrl: "https://test.example.com/Files.zip"); var package = await client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None); Assert.True(handler.RangeHeaderSent); Assert.Equal(partialContent.Length, handler.RangeStart); Assert.True(File.Exists(package.ZipPath)); } #endregion #region 6. 诊断聚合消息包含所有失败源 ID [Fact] public async Task FindLatest_AllSourcesFailed_MessageContainsAllIds() { // 使用总是返回 500 的 handler var handler = new AlwaysFailHandler(); var client = new InstallerPlondsClient( new HttpClient(handler), Path.Combine(_tempRoot, "staging-no-sources")); var ex = await Assert.ThrowsAsync( () => client.FindLatestAsync(CancellationToken.None)); // 消息应包含中文 "所有下载源均不可用" Assert.Contains("所有下载源均不可用", ex.Message, StringComparison.OrdinalIgnoreCase); // 消息应包含至少一个源 ID Assert.Contains("-", ex.Message, StringComparison.Ordinal); } [Fact] public async Task FindLatest_SingleSourceFails_MessageContainsSourceId() { var handler = new AlwaysFailHandler(); var client = new InstallerPlondsClient( new HttpClient(handler), Path.Combine(_tempRoot, "staging-single")); var ex = await Assert.ThrowsAsync( () => client.FindLatestAsync(CancellationToken.None)); // 内置源 "s3" 和 "github" 应该出现在错误消息中 Assert.Contains("s3", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("github", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task FindLatest_DiagnosticReport_AggregatesMultipleFailures() { // 使用返回 404 的 handler 模拟所有源失败 var handler = new StatusCodeHandler(HttpStatusCode.NotFound); var client = new InstallerPlondsClient( new HttpClient(handler), Path.Combine(_tempRoot, "staging-404")); var ex = await Assert.ThrowsAsync( () => client.FindLatestAsync(CancellationToken.None)); // 每行应包含一个源 ID var lines = ex.Message.Split('\n', StringSplitOptions.RemoveEmptyEntries); Assert.True(lines.Length >= 2, $"期望至少2行错误信息,实际 {lines.Length} 行"); } #endregion #region 辅助方法 private static InstallerPlondsManifest CreateManifestWithChecksum(string checksum) { return new InstallerPlondsManifest( "1", "1.0.0", "0.9.0", true, false, "stable", "windows-x64", DateTimeOffset.UtcNow, new Dictionary(), new Dictionary(), new Dictionary { ["Files.zip"] = checksum }, null, null); } private static InstallerPlondsCandidate CreateCandidate( string sha256Hash, string filesZipUrl, string version = "1.0.0") { var manifest = new InstallerPlondsManifest( "1", version, "0.9.0", true, false, "stable", "windows-x64", DateTimeOffset.UtcNow, new Dictionary(), new Dictionary(), new Dictionary { ["Files.zip"] = "sha256:" + sha256Hash }, new InstallerPlondsDownloads( new InstallerPlondsGitHubDownloads(null, null, null, filesZipUrl), null), null); return new InstallerPlondsCandidate( new InstallerPlondsSource("s3", "s3", "https://test.example.com/PLONDS.json", 100), manifest, new Uri(filesZipUrl)); } private string CreateTestZip(string content) { var dir = Path.Combine(_tempRoot, "zips"); Directory.CreateDirectory(dir); var zipPath = Path.Combine(dir, $"test-{Guid.NewGuid():N}.zip"); using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { var entry = archive.CreateEntry("test.txt"); using var writer = new StreamWriter(entry.Open()); writer.Write(content); } return zipPath; } private static string ComputeSha256(string filePath) { using var sha = SHA256.Create(); using var stream = File.OpenRead(filePath); return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant(); } private static string ComputeMd5(string filePath) { using var md5 = MD5.Create(); using var stream = File.OpenRead(filePath); return Convert.ToHexString(md5.ComputeHash(stream)).ToLowerInvariant(); } /// /// 反射调用 InstallerPlondsClient 的私有 ParseChecksum 方法进行测试。 /// private static (string Algorithm, string Hash) InvokeParseChecksum(string checksum) { var method = typeof(InstallerPlondsClient).GetMethod( "ParseChecksum", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); if (method is null) { throw new InvalidOperationException("ParseChecksum 方法未找到,请检查访问修饰符。"); } return ((string, string))method.Invoke(null, [checksum])!; } #endregion #region 测试用 HttpMessageHandler /// 总是返回 500 的 Handler。 private sealed class AlwaysFailHandler : HttpMessageHandler { private int _requestCount; public int RequestCount => Volatile.Read(ref _requestCount); protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Interlocked.Increment(ref _requestCount); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); } } /// 前 N 次返回 500,之后返回文件内容的 Handler。 private sealed class RetryThenSuccessHandler(string zipPath, int failCount) : HttpMessageHandler { private int _requestCount; public int RequestCount => Volatile.Read(ref _requestCount); protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var count = Interlocked.Increment(ref _requestCount); if (count <= failCount) { return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); } var content = File.ReadAllBytes(zipPath); var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(content) }; response.Content.Headers.ContentLength = content.Length; return Task.FromResult(response); } } /// 返回指定状态码的 Handler。 private sealed class StatusCodeHandler(HttpStatusCode statusCode) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { return Task.FromResult(new HttpResponseMessage(statusCode)); } } /// /// 支持 Range 续传的 Handler:检查 Range 头并返回对应数据片段。 /// private sealed class RangeResumeHandler(byte[] fullContent, int _expectedRangeStart) : HttpMessageHandler { public bool RangeHeaderSent { get; private set; } public long RangeStart { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // HEAD 请求:返回支持 Accept-Ranges if (request.Method == HttpMethod.Head) { var headResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Array.Empty()) }; headResponse.Content.Headers.ContentLength = fullContent.Length; headResponse.Headers.AcceptRanges.Add("bytes"); return Task.FromResult(headResponse); } // 检查 Range 头 if (request.Headers.Range is { } rangeHeader && rangeHeader.Ranges is ICollection rangeCollection && rangeCollection.Count == 1) { var range = rangeCollection.First(); if (range.From.HasValue) { RangeHeaderSent = true; RangeStart = range.From.Value; var remaining = new byte[fullContent.Length - (int)range.From.Value]; Array.Copy(fullContent, (int)range.From.Value, remaining, 0, remaining.Length); var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(remaining) }; response.Content.Headers.ContentLength = remaining.Length; response.Content.Headers.ContentRange = new System.Net.Http.Headers.ContentRangeHeaderValue( range.From.Value, fullContent.Length - 1, fullContent.Length); return Task.FromResult(response); } } // 无 Range 头:返回完整内容 var fullResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(fullContent) }; fullResponse.Content.Headers.ContentLength = fullContent.Length; return Task.FromResult(fullResponse); } } #endregion }