feat: Add PathViewer and RiskDriftCard components with templates and styles

- Implemented PathViewerComponent for visualizing reachability call paths.
- Added RiskDriftCardComponent to display reachability drift results.
- Created corresponding HTML templates and SCSS styles for both components.
- Introduced test fixtures for reachability analysis in JSON format.
- Enhanced user interaction with collapsible and expandable features in PathViewer.
- Included risk trend visualization and summary metrics in RiskDriftCard.
This commit is contained in:
master
2025-12-18 18:35:30 +02:00
parent 811f35cba7
commit 0dc71e760a
70 changed files with 8904 additions and 163 deletions

View File

@@ -0,0 +1,197 @@
// -----------------------------------------------------------------------------
// CecilMethodFingerprinterTests.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Unit tests for CecilMethodFingerprinter.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class CecilMethodFingerprinterTests
{
private readonly CecilMethodFingerprinter _fingerprinter;
public CecilMethodFingerprinterTests()
{
_fingerprinter = new CecilMethodFingerprinter(
NullLogger<CecilMethodFingerprinter>.Instance);
}
[Fact]
public void Ecosystem_ReturnsNuget()
{
Assert.Equal("nuget", _fingerprinter.Ecosystem);
}
[Fact]
public async Task FingerprintAsync_WithNullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => _fingerprinter.FingerprintAsync(null!));
}
[Fact]
public async Task FingerprintAsync_WithNonExistentPath_ReturnsEmptyResult()
{
// Arrange
var request = new FingerprintRequest
{
PackagePath = "/nonexistent/path/to/package",
PackageName = "nonexistent",
Version = "1.0.0"
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert
Assert.NotNull(result);
Assert.True(result.Success);
Assert.Empty(result.Methods);
}
[Fact]
public async Task FingerprintAsync_WithOwnAssembly_FindsMethods()
{
// Arrange - use the test assembly itself
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert
Assert.NotNull(result);
Assert.True(result.Success);
Assert.NotEmpty(result.Methods);
// Should find this test class
Assert.True(result.Methods.Count > 0, "Should find at least some methods");
}
[Fact]
public async Task FingerprintAsync_ComputesDeterministicHashes()
{
// Arrange - fingerprint twice
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result1 = await _fingerprinter.FingerprintAsync(request);
var result2 = await _fingerprinter.FingerprintAsync(request);
// Assert - same methods should produce same hashes
Assert.Equal(result1.Methods.Count, result2.Methods.Count);
foreach (var (key, fp1) in result1.Methods)
{
Assert.True(result2.Methods.TryGetValue(key, out var fp2));
Assert.Equal(fp1.BodyHash, fp2.BodyHash);
}
}
[Fact]
public async Task FingerprintAsync_WithCancellation_RespectsCancellation()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0"
};
// Act - operation may either throw or return early
// since the token is already cancelled
try
{
await _fingerprinter.FingerprintAsync(request, cts.Token);
// If it doesn't throw, that's also acceptable behavior
// The key is that it should respect the cancellation token
Assert.True(true, "Method completed without throwing - acceptable if it checks token");
}
catch (OperationCanceledException)
{
// Expected behavior
Assert.True(true);
}
}
[Fact]
public async Task FingerprintAsync_MethodKeyFormat_IsValid()
{
// Arrange
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert - keys should not be empty
foreach (var key in result.Methods.Keys)
{
Assert.NotEmpty(key);
// Method keys use "::" separator between type and method
// Some may be anonymous types like "<>f__AnonymousType0`2"
// Just verify they're non-empty and have reasonable format
Assert.True(key.Contains("::") || key.Contains("."),
$"Method key should contain :: or . separator: {key}");
}
}
[Fact]
public async Task FingerprintAsync_IncludesSignature()
{
// Arrange
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert - fingerprints should have signatures
var anyWithSignature = result.Methods.Values.Any(fp => !string.IsNullOrEmpty(fp.Signature));
Assert.True(anyWithSignature, "At least some methods should have signatures");
}
}

View File

@@ -0,0 +1,348 @@
// -----------------------------------------------------------------------------
// MethodDiffEngineTests.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Unit tests for MethodDiffEngine.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class MethodDiffEngineTests
{
private readonly MethodDiffEngine _diffEngine;
public MethodDiffEngineTests()
{
_diffEngine = new MethodDiffEngine(
NullLogger<MethodDiffEngine>.Instance);
}
[Fact]
public async Task DiffAsync_WithNullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => _diffEngine.DiffAsync(null!));
}
[Fact]
public async Task DiffAsync_WithIdenticalFingerprints_ReturnsNoChanges()
{
// Arrange
var fingerprint = CreateFingerprint("Test.Class::Method", "sha256:abc123");
var result1 = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[fingerprint.MethodKey] = fingerprint
}
};
var result2 = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[fingerprint.MethodKey] = fingerprint
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = result1,
FixedFingerprints = result2
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Empty(diff.Modified);
Assert.Empty(diff.Added);
Assert.Empty(diff.Removed);
Assert.Equal(0, diff.TotalChanges);
}
[Fact]
public async Task DiffAsync_WithModifiedMethod_ReturnsModified()
{
// Arrange
var vulnFp = CreateFingerprint("Test.Class::Method", "sha256:old_hash");
var fixedFp = CreateFingerprint("Test.Class::Method", "sha256:new_hash");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[vulnFp.MethodKey] = vulnFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[fixedFp.MethodKey] = fixedFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Single(diff.Modified);
Assert.Equal("Test.Class::Method", diff.Modified[0].MethodKey);
Assert.Equal("sha256:old_hash", diff.Modified[0].VulnVersion.BodyHash);
Assert.Equal("sha256:new_hash", diff.Modified[0].FixedVersion.BodyHash);
Assert.Empty(diff.Added);
Assert.Empty(diff.Removed);
}
[Fact]
public async Task DiffAsync_WithAddedMethod_ReturnsAdded()
{
// Arrange
var vulnFp = CreateFingerprint("Test.Class::ExistingMethod", "sha256:existing");
var newFp = CreateFingerprint("Test.Class::NewMethod", "sha256:new_method");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[vulnFp.MethodKey] = vulnFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[vulnFp.MethodKey] = vulnFp,
[newFp.MethodKey] = newFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Empty(diff.Modified);
Assert.Single(diff.Added);
Assert.Equal("Test.Class::NewMethod", diff.Added[0].MethodKey);
Assert.Empty(diff.Removed);
}
[Fact]
public async Task DiffAsync_WithRemovedMethod_ReturnsRemoved()
{
// Arrange
var existingFp = CreateFingerprint("Test.Class::ExistingMethod", "sha256:existing");
var removedFp = CreateFingerprint("Test.Class::RemovedMethod", "sha256:removed");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[existingFp.MethodKey] = existingFp,
[removedFp.MethodKey] = removedFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[existingFp.MethodKey] = existingFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Empty(diff.Modified);
Assert.Empty(diff.Added);
Assert.Single(diff.Removed);
Assert.Equal("Test.Class::RemovedMethod", diff.Removed[0].MethodKey);
}
[Fact]
public async Task DiffAsync_WithMultipleChanges_ReturnsAllChanges()
{
// Arrange - simulate a fix that modifies one method, adds one, removes one
var unchangedFp = CreateFingerprint("Test::Unchanged", "h1");
var modifiedVuln = CreateFingerprint("Test::Modified", "old");
var modifiedFixed = CreateFingerprint("Test::Modified", "new");
var removedFp = CreateFingerprint("Test::Removed", "h2");
var addedFp = CreateFingerprint("Test::Added", "h3");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[unchangedFp.MethodKey] = unchangedFp,
[modifiedVuln.MethodKey] = modifiedVuln,
[removedFp.MethodKey] = removedFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[unchangedFp.MethodKey] = unchangedFp,
[modifiedFixed.MethodKey] = modifiedFixed,
[addedFp.MethodKey] = addedFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Single(diff.Modified);
Assert.Single(diff.Added);
Assert.Single(diff.Removed);
Assert.Equal(3, diff.TotalChanges);
}
[Fact]
public async Task DiffAsync_TriggerMethods_AreModifiedOrRemoved()
{
// This test validates the key insight:
// Trigger methods (the vulnerable entry points) are typically MODIFIED or REMOVED in a fix
// They wouldn't be ADDED in the fixed version
// Arrange
var triggerMethodVuln = CreateFingerprint(
"Newtonsoft.Json.JsonConvert::DeserializeObject",
"sha256:vulnerable_impl");
var triggerMethodFixed = CreateFingerprint(
"Newtonsoft.Json.JsonConvert::DeserializeObject",
"sha256:patched_impl");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[triggerMethodVuln.MethodKey] = triggerMethodVuln
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[triggerMethodFixed.MethodKey] = triggerMethodFixed
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert - the trigger method should show as modified
Assert.True(diff.Success);
Assert.Single(diff.Modified);
Assert.Equal("Newtonsoft.Json.JsonConvert::DeserializeObject", diff.Modified[0].MethodKey);
Assert.Empty(diff.Added);
Assert.Empty(diff.Removed);
}
[Fact]
public async Task DiffAsync_WithEmptyFingerprints_ReturnsNoChanges()
{
// Arrange
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>()
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>()
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Equal(0, diff.TotalChanges);
}
private static MethodFingerprint CreateFingerprint(string methodKey, string bodyHash)
{
var parts = methodKey.Split("::");
var declaringType = parts.Length > 1 ? parts[0] : "Unknown";
var name = parts.Length > 1 ? parts[1] : parts[0];
return new MethodFingerprint
{
MethodKey = methodKey,
DeclaringType = declaringType,
Name = name,
BodyHash = bodyHash,
Signature = $"void {name}()",
IsPublic = true,
BodySize = 100
};
}
}

View File

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// NuGetPackageDownloaderTests.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-020
// Description: Unit tests for NuGetPackageDownloader.
// -----------------------------------------------------------------------------
using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using StellaOps.Scanner.VulnSurfaces.Download;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class NuGetPackageDownloaderTests : IDisposable
{
private readonly string _testOutputDir;
public NuGetPackageDownloaderTests()
{
_testOutputDir = Path.Combine(Path.GetTempPath(), $"nuget-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testOutputDir);
}
public void Dispose()
{
if (Directory.Exists(_testOutputDir))
{
try { Directory.Delete(_testOutputDir, recursive: true); }
catch { /* ignore cleanup failures */ }
}
}
[Fact]
public void Ecosystem_ReturnsNuget()
{
// Arrange
var downloader = CreateDownloader();
// Assert
Assert.Equal("nuget", downloader.Ecosystem);
}
[Fact]
public async Task DownloadAsync_WithNullRequest_ThrowsArgumentNullException()
{
// Arrange
var downloader = CreateDownloader();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(
() => downloader.DownloadAsync(null!));
}
[Fact]
public async Task DownloadAsync_WithHttpError_ReturnsFailResult()
{
// Arrange
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound,
ReasonPhrase = "Not Found"
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "NonExistent.Package",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.False(result.Success);
Assert.Contains("404", result.Error ?? "");
Assert.Null(result.ExtractedPath);
}
[Fact]
public async Task DownloadAsync_WithValidNupkg_ReturnsSuccessResult()
{
// Arrange - create a mock .nupkg (which is just a zip file)
var nupkgContent = CreateMinimalNupkg();
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new ByteArrayContent(nupkgContent)
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.True(result.Success);
Assert.NotNull(result.ExtractedPath);
Assert.NotNull(result.ArchivePath);
Assert.True(Directory.Exists(result.ExtractedPath));
Assert.True(File.Exists(result.ArchivePath));
Assert.False(result.FromCache);
}
[Fact]
public async Task DownloadAsync_WithCachedPackage_ReturnsCachedResult()
{
// Arrange - pre-create the cached directory
var packageDir = Path.Combine(_testOutputDir, "testpackage.1.0.0");
Directory.CreateDirectory(packageDir);
File.WriteAllText(Path.Combine(packageDir, "marker.txt"), "cached");
var downloader = CreateDownloader();
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = true
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.True(result.Success);
Assert.True(result.FromCache);
Assert.Equal(packageDir, result.ExtractedPath);
}
[Fact]
public async Task DownloadAsync_WithCacheFalse_BypassesCache()
{
// Arrange - pre-create the cached directory
var packageDir = Path.Combine(_testOutputDir, "testpackage.2.0.0");
Directory.CreateDirectory(packageDir);
// Set up mock to return content (we're bypassing cache)
var nupkgContent = CreateMinimalNupkg();
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new ByteArrayContent(nupkgContent)
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "2.0.0",
OutputDirectory = _testOutputDir,
UseCache = false // Bypass cache
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.True(result.Success);
Assert.False(result.FromCache);
// Verify HTTP call was made
mockHandler.Protected().Verify(
"SendAsync",
Times.Once(),
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
}
[Fact]
public async Task DownloadAsync_UsesCorrectUrl()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "Newtonsoft.Json",
Version = "13.0.3",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
await downloader.DownloadAsync(request);
// Assert
Assert.NotNull(capturedRequest);
Assert.Contains("newtonsoft.json", capturedRequest.RequestUri!.ToString());
Assert.Contains("13.0.3", capturedRequest.RequestUri!.ToString());
Assert.EndsWith(".nupkg", capturedRequest.RequestUri!.ToString());
}
[Fact]
public async Task DownloadAsync_WithCustomRegistry_UsesCustomUrl()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
RegistryUrl = "https://custom.nuget.feed.example.com/v3",
UseCache = false
};
// Act
await downloader.DownloadAsync(request);
// Assert
Assert.NotNull(capturedRequest);
Assert.StartsWith("https://custom.nuget.feed.example.com/v3", capturedRequest.RequestUri!.ToString());
}
[Fact]
public async Task DownloadAsync_WithCancellation_HonorsCancellation()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new TaskCanceledException());
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
var result = await downloader.DownloadAsync(request, cts.Token);
// Assert - should return failure, not throw
Assert.False(result.Success);
Assert.Contains("cancel", result.Error?.ToLower() ?? "");
}
private NuGetPackageDownloader CreateDownloader(HttpClient? httpClient = null)
{
var client = httpClient ?? new HttpClient();
var options = Options.Create(new NuGetDownloaderOptions());
return new NuGetPackageDownloader(
client,
NullLogger<NuGetPackageDownloader>.Instance,
options);
}
private static byte[] CreateMinimalNupkg()
{
// Create a minimal valid ZIP file (which is what a .nupkg is)
using var ms = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true))
{
// Add a minimal .nuspec file
var nuspecEntry = archive.CreateEntry("test.nuspec");
using var writer = new StreamWriter(nuspecEntry.Open());
writer.Write("""
<?xml version="1.0"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>TestPackage</id>
<version>1.0.0</version>
<authors>Test</authors>
<description>Test package</description>
</metadata>
</package>
""");
}
return ms.ToArray();
}
}