File: PowerShellScripts\ValidateNpmPackageSignaturesTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Text;
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>
/// Behavioral coverage for <c>eng/scripts/validate-npm-package-signatures.ps1</c>.
/// Drives the script with a temp Shipping directory and asserts on exit code +
/// stderr/stdout, instead of grepping the YAML that previously inlined the same
/// logic. This catches a regression in the signature heuristics (which the prior
/// content-assertion test couldn't), while staying decoupled from how/where the
/// script is wired into the pipeline.
/// </summary>
public sealed class ValidateNpmPackageSignaturesTests : IDisposable
{
    private readonly TemporaryWorkspace _workspace;
    private readonly string _scriptPath;
    private readonly ITestOutputHelper _output;
 
    public ValidateNpmPackageSignaturesTests(ITestOutputHelper output)
    {
        _output = output;
        _workspace = TemporaryWorkspace.Create(output);
        _scriptPath = Path.Combine(RepoRoot.Path, "eng", "scripts", "validate-npm-package-signatures.ps1");
    }
 
    public void Dispose() => _workspace.Dispose();
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenShippingDirectoryDoesNotExist()
    {
        var missing = Path.Combine(_workspace.Path, "does-not-exist");
 
        var result = await RunScript(missing);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Shipping packages directory not found", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenShippingDirectoryIsAFile()
    {
        // Pointing -ShippingDir at a file used to slip past Test-Path and fail
        // later inside Get-ChildItem with a less actionable message. The script
        // now rejects non-container paths up front (Test-Path -PathType
        // Container) and reports the same "not found (or not a directory)"
        // error so callers get one clear failure mode.
        var filePath = Path.Combine(_workspace.Path, "not-a-directory.txt");
        File.WriteAllText(filePath, "hi");
 
        var result = await RunScript(filePath);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Shipping packages directory not found", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenNoNpmTarballsFound()
    {
        var shipping = CreateShipping();
        // Drop in a totally unrelated file so the directory isn't empty —
        // the failure must be specific ("no aspire CLI npm packages"), not
        // a generic "empty directory" message.
        File.WriteAllText(Path.Combine(shipping, "Aspire.Hosting.13.4.0.nupkg"), "decoy");
 
        var result = await RunScript(shipping);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("No Aspire CLI npm packages were found", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenSignatureSidecarIsMissing()
    {
        var shipping = CreateShipping();
        var tgz = Path.Combine(shipping, "microsoft-aspire-cli-linux-x64-13.4.0.tgz");
        File.WriteAllBytes(tgz, [0x1f, 0x8b]); // anything; content not inspected
        // No .sig file alongside it.
 
        var result = await RunScript(shipping);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Missing detached signature sidecar", result.Output);
        Assert.Contains("microsoft-aspire-cli-linux-x64-13.4.0.tgz.sig", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenSignatureIsShorterThanMinimumLength()
    {
        var shipping = CreateShipping();
        var tgz = Path.Combine(shipping, "microsoft-aspire-cli-linux-x64-13.4.0.tgz");
        File.WriteAllBytes(tgz, [0x1f, 0x8b]);
        // 32 bytes — under the 64-byte minimum the script enforces. Avoids the
        // false-positive case where an empty-but-padded sidecar slips through.
        File.WriteAllBytes($"{tgz}.sig", Enumerable.Repeat((byte)0xC2, 32).ToArray());
 
        var result = await RunScript(shipping);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("content sanity check", result.Output);
        Assert.Contains("only 32 bytes", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task FailsWhenSignatureHasNoPgpMarker()
    {
        var shipping = CreateShipping();
        var tgz = Path.Combine(shipping, "microsoft-aspire-cli-linux-x64-13.4.0.tgz");
        File.WriteAllBytes(tgz, [0x1f, 0x8b]);
        // 128 bytes of garbage starting with 0x00 — neither armored ("-----BEGIN")
        // nor an OpenPGP packet tag in the accepted ranges (0x88..0x8B old
        // format, 0xC2 new format; see RFC 9580 §4.3 / §5.2).
        File.WriteAllBytes($"{tgz}.sig", new byte[128]);
 
        var result = await RunScript(shipping);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("content sanity check", result.Output);
        Assert.Contains("no PGP signature marker", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task SucceedsForArmoredPgpSignature()
    {
        var shipping = CreateShipping();
        var tgz = Path.Combine(shipping, "microsoft-aspire-cli-linux-x64-13.4.0.tgz");
        File.WriteAllBytes(tgz, [0x1f, 0x8b]);
        var armored = Encoding.ASCII.GetBytes(
            "-----BEGIN PGP SIGNATURE-----\n" +
            "Version: GnuPG v2\n\n" +
            new string('A', 256) + "\n" +
            "-----END PGP SIGNATURE-----\n");
        File.WriteAllBytes($"{tgz}.sig", armored);
 
        var result = await RunScript(shipping);
 
        result.EnsureSuccessful();
        Assert.Contains("Validated 1 Aspire CLI npm package signature sidecar", result.Output);
    }
 
    [Theory]
    [InlineData(0x88)]
    [InlineData(0x89)]
    [InlineData(0x8A)]
    [InlineData(0x8B)]
    [InlineData(0xC2)]
    [RequiresTools(["pwsh"])]
    public async Task SucceedsForBinaryOpenPgpSignaturePacket(int firstByteInt)
    {
        var firstByte = (byte)firstByteInt;
        var shipping = CreateShipping();
        var tgz = Path.Combine(shipping, "microsoft-aspire-cli-linux-x64-13.4.0.tgz");
        File.WriteAllBytes(tgz, [0x1f, 0x8b]);
        // Binary OpenPGP signature packet — old-format tags 0x88..0x8B (signature
        // packet, tag 2) and new-format 0xC2 are the marker bytes the script
        // accepts. Pad to 128 bytes; the script only reads the first 64.
        var sig = new byte[128];
        sig[0] = firstByte;
        File.WriteAllBytes($"{tgz}.sig", sig);
 
        var result = await RunScript(shipping);
 
        result.EnsureSuccessful();
        Assert.Contains("Validated 1 Aspire CLI npm package signature sidecar", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task ReportsAllFailuresInOnePass()
    {
        // Two tarballs — one missing a sidecar, one with a wrong-prefix sidecar.
        // The script must surface BOTH failure categories in a single run so
        // operators diagnosing a real signing outage see every problem on one
        // CI run instead of fixing one and rediscovering the next.
        var shipping = CreateShipping();
        var tgzMissing = Path.Combine(shipping, "microsoft-aspire-cli-linux-x64-13.4.0.tgz");
        var tgzBadPrefix = Path.Combine(shipping, "microsoft-aspire-cli-win-x64-13.4.0.tgz");
        File.WriteAllBytes(tgzMissing, [0x1f, 0x8b]);
        File.WriteAllBytes(tgzBadPrefix, [0x1f, 0x8b]);
        File.WriteAllBytes($"{tgzBadPrefix}.sig", new byte[128]); // bad prefix
 
        var result = await RunScript(shipping);
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Missing detached signature sidecar", result.Output);
        Assert.Contains("microsoft-aspire-cli-linux-x64-13.4.0.tgz.sig", result.Output);
        Assert.Contains("content sanity check", result.Output);
        Assert.Contains("microsoft-aspire-cli-win-x64-13.4.0.tgz.sig", result.Output);
    }
 
    private async Task<CommandResult> RunScript(string shippingDir)
    {
        using var cmd = new PowerShellCommand(_scriptPath, _output)
            .WithTimeout(TimeSpan.FromMinutes(1));
 
        return await cmd.ExecuteAsync(
            "-ShippingDir", $"\"{shippingDir}\"");
    }
 
    private string CreateShipping()
    {
        var unique = Path.GetRandomFileName();
        var path = Path.Combine(_workspace.Path, unique, "Shipping");
        Directory.CreateDirectory(path);
        return path;
    }
}