File: Pipelines\NixCliPackageTests.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.Json.Nodes;
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests;
 
public sealed class NixCliPackageTests(ITestOutputHelper outputHelper) : IDisposable
{
    private static readonly Dictionary<string, string> s_expectedSystems = new(StringComparer.Ordinal)
    {
        ["aarch64-darwin"] = "osx-arm64",
        ["aarch64-linux"] = "linux-arm64",
        ["x86_64-darwin"] = "osx-x64",
        ["x86_64-linux"] = "linux-x64",
    };
 
    private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(outputHelper);
 
    public void Dispose() => _workspace.Dispose();
 
    [Fact]
    public async Task ManifestDescribesExpectedStableReleaseAssets()
    {
        var manifest = await ReadJsonObjectAsync("eng/nix/versions.json");
 
        var version = GetRequiredString(manifest, "version");
        Assert.Matches(@"^\d+\.\d+\.\d+$", version);
        Assert.Equal($"v{version}", GetRequiredString(manifest, "releaseTag"));
 
        var systems = GetRequiredObject(manifest, "systems");
        Assert.Equal(
            s_expectedSystems.Keys.Order(StringComparer.Ordinal),
            systems.Select(system => system.Key).Order(StringComparer.Ordinal));
 
        foreach (var (system, rid) in s_expectedSystems)
        {
            var entry = GetRequiredObject(systems, system);
            var archiveName = $"aspire-cli-{rid}-{version}.tar.gz";
            Assert.Equal(rid, GetRequiredString(entry, "rid"));
            Assert.Equal(archiveName, GetRequiredString(entry, "archiveName"));
 
            var url = new Uri(GetRequiredString(entry, "url"));
            Assert.Equal("https", url.Scheme);
            Assert.Equal("github.com", url.Host);
            Assert.Equal($"/microsoft/aspire/releases/download/v{version}/{archiveName}", url.AbsolutePath);
 
            var hash = GetRequiredString(entry, "hash");
            Assert.StartsWith("sha512-", hash, StringComparison.Ordinal);
            Assert.Equal(64, Convert.FromBase64String(hash["sha512-".Length..]).Length);
        }
    }
 
    [Fact]
    public async Task ManifestVersionMatchesPackageValidationBaselineVersion()
    {
        var manifest = await ReadJsonObjectAsync("eng/nix/versions.json");
        var directoryBuildProps = await ReadRepoFileAsync("src/Directory.Build.props");
        var match = System.Text.RegularExpressions.Regex.Match(
            directoryBuildProps,
            @"<PackageValidationBaselineVersion[^>]*>([^<]+)</PackageValidationBaselineVersion>");
 
        Assert.True(match.Success, "Expected PackageValidationBaselineVersion in src/Directory.Build.props.");
        Assert.Equal(match.Groups[1].Value, GetRequiredString(manifest, "version"));
    }
 
    [Fact]
    [RequiresTools(["bash", "base64", "xxd"])]
    public async Task UpdateVersionsParsesFirstHashTokenFromSha512CompanionFile()
    {
        var outputPath = Path.Combine(_workspace.Path, "versions.json");
        var fakeBinPath = Path.Combine(_workspace.Path, "bin");
        Directory.CreateDirectory(fakeBinPath);
 
        var curlPath = Path.Combine(fakeBinPath, "curl");
        await File.WriteAllTextAsync(curlPath, """
            #!/usr/bin/env bash
            set -euo pipefail
            url="${@: -1}"
            printf '%s  %s\n' 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' "${url##*/}"
            """);
 
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(curlPath,
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
                UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
                UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
        }
 
        var result = await RunBashAsync(
            Path.Combine(RepoRoot.Path, "eng", "nix", "update-versions.sh"),
            ["--version", "13.4.0", "--output-path", outputPath],
            new Dictionary<string, string?>
            {
                ["PATH"] = fakeBinPath + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
            });
 
        Assert.True(result.ExitCode == 0, $"Expected update-versions.sh to succeed.{Environment.NewLine}{result.Output}");
 
        var manifest = await ReadJsonObjectAsync(outputPath);
        var systems = GetRequiredObject(manifest, "systems");
        var expectedHash = "sha512-" + Convert.ToBase64String(Convert.FromHexString(new string('a', 128)));
 
        foreach (var system in s_expectedSystems.Keys)
        {
            var entry = GetRequiredObject(systems, system);
            Assert.Equal(expectedHash, GetRequiredString(entry, "hash"));
        }
    }
 
    [Fact]
    [RequiresTools(["bash", "base64", "xxd"])]
    public async Task UpdateVersionsUsesSuppliedSha512OverridesWithoutReadingRelease()
    {
        var outputPath = Path.Combine(_workspace.Path, "versions.json");
        var fakeBinPath = Path.Combine(_workspace.Path, "bin");
        Directory.CreateDirectory(fakeBinPath);
 
        // A curl stub that always fails. Offline mode (--sha512 supplied) must
        // never invoke curl, so its presence here proves no network read happens.
        var curlPath = Path.Combine(fakeBinPath, "curl");
        await File.WriteAllTextAsync(curlPath, """
            #!/usr/bin/env bash
            echo 'curl should not be called in offline mode' >&2
            exit 1
            """);
 
        if (!OperatingSystem.IsWindows())
        {
            File.SetUnixFileMode(curlPath,
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
                UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
                UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
        }
 
        // Distinct digest per RID so a mismapped override would fail the assert.
        var hexByRid = new Dictionary<string, string>(StringComparer.Ordinal)
        {
            ["osx-arm64"] = new string('a', 128),
            ["osx-x64"] = new string('b', 128),
            ["linux-arm64"] = new string('c', 128),
            ["linux-x64"] = new string('d', 128),
        };
 
        var arguments = new List<string> { "--version", "13.4.0", "--output-path", outputPath };
        foreach (var (rid, hex) in hexByRid)
        {
            arguments.Add("--sha512");
            arguments.Add($"{rid}={hex}");
        }
 
        var result = await RunBashAsync(
            Path.Combine(RepoRoot.Path, "eng", "nix", "update-versions.sh"),
            arguments.ToArray(),
            new Dictionary<string, string?>
            {
                ["PATH"] = fakeBinPath + Path.PathSeparator + Environment.GetEnvironmentVariable("PATH")
            });
 
        Assert.True(result.ExitCode == 0, $"Expected update-versions.sh to succeed in offline mode.{Environment.NewLine}{result.Output}");
 
        var manifest = await ReadJsonObjectAsync(outputPath);
        var systems = GetRequiredObject(manifest, "systems");
 
        foreach (var (system, rid) in s_expectedSystems)
        {
            var entry = GetRequiredObject(systems, system);
            var expectedHash = "sha512-" + Convert.ToBase64String(Convert.FromHexString(hexByRid[rid]));
            Assert.Equal(expectedHash, GetRequiredString(entry, "hash"));
        }
    }
 
    [Fact]
    [RequiresTools(["bash", "base64", "xxd"])]
    public async Task UpdateVersionsRejectsPartialSha512Overrides()
    {
        var outputPath = Path.Combine(_workspace.Path, "versions.json");
 
        // Supply only one platform's digest. Offline mode requires all four, so
        // the script must fail rather than silently curl the missing three.
        var result = await RunBashAsync(
            Path.Combine(RepoRoot.Path, "eng", "nix", "update-versions.sh"),
            ["--version", "13.4.0", "--output-path", outputPath, "--sha512", $"osx-arm64={new string('a', 128)}"],
            new Dictionary<string, string?>
            {
                ["PATH"] = Environment.GetEnvironmentVariable("PATH")
            });
 
        Assert.False(result.ExitCode == 0, $"Expected update-versions.sh to fail with a partial override set.{Environment.NewLine}{result.Output}");
        Assert.Contains("no --sha512 override supplied for rid", result.Output, StringComparison.Ordinal);
        Assert.False(File.Exists(outputPath), "versions.json must not be written when the override set is incomplete.");
    }
 
    [Fact]
    public async Task FlakeLockPinsNixpkgsInput()
    {
        var flakeLock = await ReadJsonObjectAsync("flake.lock");
        var nodes = GetRequiredObject(flakeLock, "nodes");
        var nixpkgs = GetRequiredObject(nodes, "nixpkgs");
        var locked = GetRequiredObject(nixpkgs, "locked");
        var original = GetRequiredObject(nixpkgs, "original");
 
        Assert.Equal("github", GetRequiredString(locked, "type"));
        Assert.Equal("NixOS", GetRequiredString(locked, "owner"));
        Assert.Equal("nixpkgs", GetRequiredString(locked, "repo"));
        Assert.StartsWith("sha256-", GetRequiredString(locked, "narHash"), StringComparison.Ordinal);
        Assert.Equal("nixos-unstable", GetRequiredString(original, "ref"));
    }
 
    private static async Task<JsonObject> ReadJsonObjectAsync(string relativePath)
    {
        var contents = await ReadRepoFileAsync(relativePath);
        return JsonNode.Parse(contents)?.AsObject()
            ?? throw new InvalidOperationException($"Could not parse {relativePath} as a JSON object.");
    }
 
    private static async Task<CommandResult> RunBashAsync(string scriptPath, string[] arguments, Dictionary<string, string?> environment)
    {
        using var process = new System.Diagnostics.Process();
        process.StartInfo.FileName = "bash";
        process.StartInfo.ArgumentList.Add(scriptPath);
 
        foreach (var argument in arguments)
        {
            process.StartInfo.ArgumentList.Add(argument);
        }
 
        process.StartInfo.RedirectStandardError = true;
        process.StartInfo.RedirectStandardOutput = true;
        process.StartInfo.UseShellExecute = false;
 
        foreach (var (name, value) in environment)
        {
            process.StartInfo.Environment[name] = value;
        }
 
        process.Start();
        var outputTask = process.StandardOutput.ReadToEndAsync();
        var errorTask = process.StandardError.ReadToEndAsync();
        using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        await process.WaitForExitAsync(cancellationTokenSource.Token);
 
        var output = await outputTask + await errorTask;
 
        return new CommandResult(process.ExitCode, output);
    }
 
    private static Task<string> ReadRepoFileAsync(string relativePath)
        => Path.IsPathRooted(relativePath)
            ? File.ReadAllTextAsync(relativePath)
            : File.ReadAllTextAsync(Path.Combine(RepoRoot.Path, relativePath));
 
    private static JsonObject GetRequiredObject(JsonObject obj, string propertyName)
    {
        Assert.True(obj.TryGetPropertyValue(propertyName, out var value), $"Expected property '{propertyName}'.");
        return Assert.IsType<JsonObject>(value);
    }
 
    private static string GetRequiredString(JsonObject obj, string propertyName)
    {
        Assert.True(obj.TryGetPropertyValue(propertyName, out var value), $"Expected property '{propertyName}'.");
        return Assert.IsAssignableFrom<JsonValue>(value).GetValue<string>();
    }
 
    private sealed record CommandResult(int ExitCode, string Output);
}