518 lines
18 KiB
C#
518 lines
18 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey;
|
|
using Xunit;
|
|
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Scanner.Analyzers.OS.Windows.Chocolatey.Tests;
|
|
|
|
public class ChocolateyPackageAnalyzerTests
|
|
{
|
|
private readonly ChocolateyPackageAnalyzer _analyzer;
|
|
private readonly ILogger _logger;
|
|
|
|
public ChocolateyPackageAnalyzerTests()
|
|
{
|
|
_logger = NullLoggerFactory.Instance.CreateLogger<ChocolateyPackageAnalyzer>();
|
|
_analyzer = new ChocolateyPackageAnalyzer((ILogger<ChocolateyPackageAnalyzer>)_logger);
|
|
}
|
|
|
|
private OSPackageAnalyzerContext CreateContext(string rootPath)
|
|
{
|
|
return new OSPackageAnalyzerContext(
|
|
rootPath,
|
|
workspacePath: null,
|
|
TimeProvider.System,
|
|
_logger);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public void AnalyzerId_ReturnsCorrectValue()
|
|
{
|
|
Assert.Equal("windows-chocolatey", _analyzer.AnalyzerId);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WithNoChocolateyDirectory_ReturnsEmptyList()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
Directory.CreateDirectory(tempDir);
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Empty(result.Packages);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WithEmptyChocolateyLib_ReturnsEmptyList()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
Directory.CreateDirectory(chocoLib);
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.NotNull(result);
|
|
Assert.Empty(result.Packages);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WithNuspecFile_ReturnsPackageRecord()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(result.Packages);
|
|
var record = result.Packages[0];
|
|
|
|
Assert.Equal("Git", record.Name);
|
|
Assert.Equal("2.42.0", record.Version);
|
|
Assert.Equal("pkg:chocolatey/git@2.42.0", record.PackageUrl);
|
|
Assert.Equal(PackageEvidenceSource.WindowsChocolatey, record.EvidenceSource);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WithMultiplePackages_ReturnsAllRecords()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
|
|
var package1Dir = Path.Combine(chocoLib, "git.2.42.0");
|
|
var package2Dir = Path.Combine(chocoLib, "nodejs.20.10.0");
|
|
var package3Dir = Path.Combine(chocoLib, "7zip.23.01");
|
|
|
|
Directory.CreateDirectory(package1Dir);
|
|
Directory.CreateDirectory(package2Dir);
|
|
Directory.CreateDirectory(package3Dir);
|
|
|
|
CreateNuspecFile(package1Dir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
|
CreateNuspecFile(package2Dir, "nodejs", "20.10.0", "Node.js", "Node.js Foundation", "Node.js runtime");
|
|
CreateNuspecFile(package3Dir, "7zip", "23.01", "7-Zip", "Igor Pavlov", "File archiver");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Equal(3, result.Packages.Count);
|
|
|
|
var git = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("git"));
|
|
Assert.NotNull(git);
|
|
Assert.Equal("2.42.0", git.Version);
|
|
|
|
var node = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("nodejs"));
|
|
Assert.NotNull(node);
|
|
Assert.Equal("20.10.0", node.Version);
|
|
|
|
var sevenZip = result.Packages.FirstOrDefault(r => r.PackageUrl.Contains("7zip"));
|
|
Assert.NotNull(sevenZip);
|
|
Assert.Equal("23.01", sevenZip.Version);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_ExtractsVendorMetadata()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
var packageDir = Path.Combine(chocoLib, "vscode.1.85.0");
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
CreateNuspecFile(packageDir, "vscode", "1.85.0", "Visual Studio Code",
|
|
"Microsoft", "Visual Studio Code editor",
|
|
"https://code.visualstudio.com",
|
|
"https://code.visualstudio.com/license");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(result.Packages);
|
|
var record = result.Packages[0];
|
|
|
|
Assert.Equal("vscode", record.VendorMetadata["choco:id"]);
|
|
Assert.Equal("1.85.0", record.VendorMetadata["choco:version"]);
|
|
Assert.Equal("Visual Studio Code", record.VendorMetadata["choco:title"]);
|
|
Assert.Equal("Microsoft", record.VendorMetadata["choco:authors"]);
|
|
Assert.Equal("https://code.visualstudio.com", record.VendorMetadata["choco:project_url"]);
|
|
Assert.Equal("https://code.visualstudio.com/license", record.VendorMetadata["choco:license_url"]);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WithInstallScript_ComputesHash()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
|
var toolsDir = Path.Combine(packageDir, "tools");
|
|
Directory.CreateDirectory(toolsDir);
|
|
|
|
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
|
File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing Git'");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(result.Packages);
|
|
var record = result.Packages[0];
|
|
|
|
Assert.True(record.VendorMetadata.ContainsKey("choco:install_script_hash"));
|
|
Assert.StartsWith("sha256:", record.VendorMetadata["choco:install_script_hash"]);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_FallsBackToDirectoryParsing_WhenNoNuspec()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
var packageDir = Path.Combine(chocoLib, "python.3.12.0");
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
// Create a file but no nuspec
|
|
File.WriteAllText(Path.Combine(packageDir, "dummy.txt"), "placeholder");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(result.Packages);
|
|
var record = result.Packages[0];
|
|
|
|
Assert.Equal("python", record.Name);
|
|
Assert.Equal("3.12.0", record.Version);
|
|
Assert.Equal("pkg:chocolatey/python@3.12.0", record.PackageUrl);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_IncludesFileEvidence()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
|
var toolsDir = Path.Combine(packageDir, "tools");
|
|
Directory.CreateDirectory(toolsDir);
|
|
|
|
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Git Authors", "Git for Windows");
|
|
File.WriteAllText(Path.Combine(toolsDir, "chocolateyinstall.ps1"), "Write-Host 'Installing'");
|
|
File.WriteAllText(Path.Combine(toolsDir, "helper.bat"), "@echo off");
|
|
File.WriteAllText(Path.Combine(packageDir, "config.json"), "{}");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(result.Packages);
|
|
var record = result.Packages[0];
|
|
|
|
// Should include key files (ps1, bat, json, nuspec)
|
|
Assert.Contains(record.Files, f => f.Path.EndsWith(".ps1"));
|
|
Assert.Contains(record.Files, f => f.Path.EndsWith(".bat"));
|
|
Assert.Contains(record.Files, f => f.Path.EndsWith(".json"));
|
|
Assert.Contains(record.Files, f => f.Path.EndsWith(".nuspec"));
|
|
|
|
// Config file should be marked as config
|
|
var configFile = record.Files.FirstOrDefault(f => f.Path.EndsWith(".json"));
|
|
Assert.NotNull(configFile);
|
|
Assert.True(configFile.IsConfigFile);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_ResultsAreSortedDeterministically()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
|
|
// Create packages in random order
|
|
var zetaDir = Path.Combine(chocoLib, "zeta.1.0.0");
|
|
var alphaDir = Path.Combine(chocoLib, "alpha.1.0.0");
|
|
var midDir = Path.Combine(chocoLib, "mid.1.0.0");
|
|
|
|
Directory.CreateDirectory(zetaDir);
|
|
Directory.CreateDirectory(alphaDir);
|
|
Directory.CreateDirectory(midDir);
|
|
|
|
CreateNuspecFile(zetaDir, "zeta", "1.0.0", "Zeta", "Author", "Zeta package");
|
|
CreateNuspecFile(alphaDir, "alpha", "1.0.0", "Alpha", "Author", "Alpha package");
|
|
CreateNuspecFile(midDir, "mid", "1.0.0", "Mid", "Author", "Mid package");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert - Results should be sorted by PURL
|
|
Assert.Equal(3, result.Packages.Count);
|
|
Assert.Equal("Alpha", result.Packages[0].Name);
|
|
Assert.Equal("Mid", result.Packages[1].Name);
|
|
Assert.Equal("Zeta", result.Packages[2].Name);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_SkipsHiddenDirectories()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
|
|
var validDir = Path.Combine(chocoLib, "git.2.42.0");
|
|
var hiddenDir = Path.Combine(chocoLib, ".hidden");
|
|
|
|
Directory.CreateDirectory(validDir);
|
|
Directory.CreateDirectory(hiddenDir);
|
|
|
|
CreateNuspecFile(validDir, "git", "2.42.0", "Git", "Author", "Git");
|
|
CreateNuspecFile(hiddenDir, "hidden", "1.0.0", "Hidden", "Author", "Hidden package");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert - Only valid package should be returned
|
|
Assert.Single(result.Packages);
|
|
Assert.Equal("Git", result.Packages[0].Name);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_HandlesLowerCaseChocolateyPath()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "chocolatey", "lib"); // lowercase
|
|
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Author", "Git");
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(result.Packages);
|
|
Assert.Equal("Git", result.Packages[0].Name);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_TruncatesLongDescription()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
var packageDir = Path.Combine(chocoLib, "longdesc.1.0.0");
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
var longDescription = new string('A', 500);
|
|
CreateNuspecFile(packageDir, "longdesc", "1.0.0", "LongDesc", "Author", longDescription);
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act
|
|
var result = await _analyzer.AnalyzeAsync(context, TestContext.Current.CancellationToken);
|
|
|
|
// Assert
|
|
Assert.Single(result.Packages);
|
|
var record = result.Packages[0];
|
|
|
|
var description = record.VendorMetadata["choco:description"];
|
|
Assert.True(description!.Length <= 200);
|
|
Assert.EndsWith("...", description);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task AnalyzeAsync_WithCancellation_ThrowsOperationCanceledException()
|
|
{
|
|
// Arrange
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "choco-analyzer-test-" + Guid.NewGuid().ToString("N")[..8]);
|
|
var chocoLib = Path.Combine(tempDir, "ProgramData", "Chocolatey", "lib");
|
|
var packageDir = Path.Combine(chocoLib, "git.2.42.0");
|
|
Directory.CreateDirectory(packageDir);
|
|
|
|
CreateNuspecFile(packageDir, "git", "2.42.0", "Git", "Author", "Git");
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
try
|
|
{
|
|
var context = CreateContext(tempDir);
|
|
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<OperationCanceledException>(
|
|
async () => await _analyzer.AnalyzeAsync(context, cts.Token));
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(tempDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static void CreateNuspecFile(
|
|
string packageDir,
|
|
string id,
|
|
string version,
|
|
string title,
|
|
string authors,
|
|
string description,
|
|
string? projectUrl = null,
|
|
string? licenseUrl = null)
|
|
{
|
|
var nuspecPath = Path.Combine(packageDir, $"{id}.nuspec");
|
|
|
|
var projectUrlElement = projectUrl is not null ? $"<projectUrl>{projectUrl}</projectUrl>" : "";
|
|
var licenseUrlElement = licenseUrl is not null ? $"<licenseUrl>{licenseUrl}</licenseUrl>" : "";
|
|
|
|
var content = $@"<?xml version=""1.0"" encoding=""utf-8""?>
|
|
<package xmlns=""http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"">
|
|
<metadata>
|
|
<id>{id}</id>
|
|
<version>{version}</version>
|
|
<title>{title}</title>
|
|
<authors>{authors}</authors>
|
|
<description>{description}</description>
|
|
{projectUrlElement}
|
|
{licenseUrlElement}
|
|
</metadata>
|
|
</package>";
|
|
|
|
File.WriteAllText(nuspecPath, content);
|
|
}
|
|
}
|