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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user