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(); _analyzer = new ChocolateyPackageAnalyzer((ILogger)_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( 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}" : ""; var licenseUrlElement = licenseUrl is not null ? $"{licenseUrl}" : ""; var content = $@" {id} {version} {title} {authors} {description} {projectUrlElement} {licenseUrlElement} "; File.WriteAllText(nuspecPath, content); } }