// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Aspire.Templates.Tests;
using Microsoft.DotNet.XUnitExtensions;
using Xunit;
namespace Aspire.Acquisition.Tests.Scripts;
/// <summary>
/// Tests for the bash PR script (get-aspire-cli-pr.sh).
/// These tests validate parameter handling with mock gh CLI.
/// </summary>
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public class PRScriptShellTests(ITestOutputHelper testOutput)
{
private static readonly string s_scriptPath = ScriptPaths.PRShell;
private readonly ITestOutputHelper _testOutput = testOutput;
private async Task<ScriptToolCommand> CreateCommandWithMockGhAsync(TestEnvironment env)
{
var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
return cmd;
}
[Fact]
public async Task HelpFlag_ShowsUsage()
{
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var result = await cmd.ExecuteAsync("--help");
result.EnsureSuccessful();
Assert.Contains("Usage", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AllMainParameters_ShownInHelp()
{
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var result = await cmd.ExecuteAsync("--help");
result.EnsureSuccessful();
Assert.Contains("--run-id", result.Output);
Assert.Contains("--local-dir", result.Output);
Assert.Contains("--hive-label", result.Output);
Assert.Contains("--install-path", result.Output);
Assert.Contains("--os", result.Output);
Assert.Contains("--arch", result.Output);
Assert.Contains("--hive-only", result.Output);
Assert.Contains("--skip-extension", result.Output);
Assert.Contains("--use-insiders", result.Output);
Assert.Contains("--skip-path", result.Output);
Assert.Contains("--keep-archive", result.Output);
Assert.Contains("--verbose", result.Output);
Assert.Contains("--dry-run", result.Output);
}
[Fact]
public async Task MissingPRNumberAndRunId_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var result = await cmd.ExecuteAsync("--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("PR number", result.Output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("--run-id", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task DryRunWithPRNumber_ShowsSteps()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path");
result.EnsureSuccessful();
Assert.Contains("12345", result.Output);
Assert.Contains("[DRY RUN]", result.Output);
}
[Fact]
public async Task MockGhApiWorkflowWithoutJqFailsLoudly()
{
using var env = new TestEnvironment();
var mockBinPath = await env.CreateMockGhScriptAsync(_testOutput);
var mockGhPath = Path.Combine(mockBinPath, "gh");
using var cmd = new ToolCommand(mockGhPath, _testOutput, "mock-gh");
var result = await cmd.ExecuteAsync("api", "repos/microsoft/aspire/actions/workflows/ci.yml/runs?event=pull_request&head_sha=abc123");
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("_mock_missing_jq", result.Output);
}
[Fact]
public async Task MockGhApiWorkflowWithJqReturnsRunId()
{
using var env = new TestEnvironment();
var mockBinPath = await env.CreateMockGhScriptAsync(_testOutput);
var mockGhPath = Path.Combine(mockBinPath, "gh");
using var cmd = new ToolCommand(mockGhPath, _testOutput, "mock-gh");
var result = await cmd.ExecuteAsync("api", "repos/microsoft/aspire/actions/workflows/ci.yml/runs?event=pull_request&head_sha=abc123", "--jq", ".workflow_runs[0].id");
result.EnsureSuccessful();
Assert.Equal("987654321", result.Output.Trim());
}
[Fact]
public async Task CustomInstallPath_IsRecognized()
{
using var env = new TestEnvironment();
var customPath = Path.Combine(env.TempDirectory, "custom");
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--install-path", customPath);
result.EnsureSuccessful();
Assert.Contains(customPath, result.Output);
}
[Fact]
public async Task RunIdParameter_IsRecognized()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--run-id", "987654321");
result.EnsureSuccessful();
Assert.Contains("987654321", result.Output);
}
[Fact]
public async Task LocalDir_DryRun_UsesLocalDirectoryWithoutGh()
{
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var localDir = Path.Combine(env.TempDirectory, "local artifacts");
Directory.CreateDirectory(localDir);
await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-preview.1.12345.1");
var result = await cmd.ExecuteAsync(
"--local-dir", localDir,
"--hive-label", "test-hive",
"--hive-only",
"--dry-run");
result.EnsureSuccessful();
Assert.Contains("Installing from local directory", result.Output);
Assert.Contains(localDir, result.Output);
Assert.Contains("test-hive", result.Output);
Assert.DoesNotContain("Downloading CLI from GitHub", result.Output);
}
[Fact]
public async Task LocalDir_DryRun_AutoDetectsRawBuildOutput()
{
// When --local-dir points at a directory that contains only a raw 'aspire' executable
// (no aspire-cli-*.tar.gz/.zip archive), auto-detect must dispatch to the raw-build flow
// (install_aspire_cli_from_binary) instead of erroring on a missing archive.
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var localDir = Path.Combine(env.TempDirectory, "raw-build");
Directory.CreateDirectory(localDir);
// Drop a fake 'aspire' executable — no archive, no nupkgs.
await File.WriteAllTextAsync(Path.Combine(localDir, "aspire"), "#!/bin/sh\necho fake\n");
var result = await cmd.ExecuteAsync(
"--local-dir", localDir,
"--hive-label", "test-hive",
"--dry-run",
"--skip-path");
result.EnsureSuccessful();
Assert.Contains("Installing from local directory", result.Output);
Assert.Contains("[DRY RUN] Would install raw CLI binary", result.Output);
Assert.DoesNotContain("Downloading CLI from GitHub", result.Output);
}
[Fact]
public async Task RunIdAsFirstArg_DryRun_Succeeds()
{
// Verifies --run-id is recognized as a first-positional flag. Pair with
// --hive-label because --run-id alone is rejected (the installed CLI's
// channel is baked at build time and never points at a 'run-<id>' hive).
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("--run-id", "987654321", "--hive-label", "pr-12345", "--dry-run", "--skip-path");
result.EnsureSuccessful();
Assert.Contains("987654321", result.Output);
Assert.Contains("[DRY RUN]", result.Output);
}
[Fact]
public async Task RunIdOnly_NoHiveLabel_DryRun_Rejected()
{
// Regression guard: --run-id alone (without --pr-number / --hive-label) must
// fail with actionable guidance. The installed CLI's channel is baked at build
// time (pr-<N>/staging/daily/local); 'run-<id>' is never a baked channel, so a
// hives/run-<id>/packages layout would be unreachable from the CLI.
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("--run-id", "987654321", "--dry-run", "--skip-path", "--verbose");
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Cannot determine hive label from --run-id alone", result.Output);
Assert.Contains("--pr-number", result.Output);
Assert.Contains("--hive-label", result.Output);
Assert.DoesNotContain("run-987654321", result.Output);
}
[Fact]
public async Task RunIdWithPRNumber_DryRun_UsesPrHiveLabel()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--run-id", "987654321", "--dry-run", "--skip-path", "--verbose");
result.EnsureSuccessful();
Assert.Contains("pr-12345", result.Output);
}
[Fact]
public async Task RunIdAsFirstArg_NonNumericValue_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("--run-id", "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Run ID must be a number", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RunIdAsFirstArg_NoValue_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var result = await cmd.ExecuteAsync("--run-id");
Assert.NotEqual(0, result.ExitCode);
}
[Fact]
public async Task ShortRunIdFlag_AsFirstArg_DryRun_Succeeds()
{
// Verifies -r is recognized as a first-positional alias for --run-id. Pair
// with --hive-label because --run-id alone is rejected.
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("-r", "987654321", "--hive-label", "pr-12345", "--dry-run", "--skip-path");
result.EnsureSuccessful();
Assert.Contains("987654321", result.Output);
}
[Fact]
public async Task OSOverride_IsRecognized()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--os", "linux");
result.EnsureSuccessful();
Assert.Contains("linux", result.Output);
}
[Fact]
public async Task ArchOverride_IsRecognized()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--arch", "x64");
result.EnsureSuccessful();
Assert.Contains("x64", result.Output);
}
[Fact]
public async Task HiveOnlyFlag_IsRecognized()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--hive-only");
result.EnsureSuccessful();
Assert.Contains("hive-only", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SkipExtensionFlag_IsRecognized()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--skip-extension");
result.EnsureSuccessful();
Assert.Contains("skip-extension", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SkipPathFlag_IsRecognized()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path");
result.EnsureSuccessful();
Assert.Contains("Skipping PATH", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("--use-insiders")]
[InlineData("--verbose")]
[InlineData("--keep-archive")]
public async Task BooleanFlags_AreAccepted(string flag)
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", flag);
result.EnsureSuccessful();
}
[Fact]
public async Task MultipleFlags_WorkTogether()
{
using var env = new TestEnvironment();
var customPath = Path.Combine(env.TempDirectory, "custom");
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"12345",
"--dry-run",
"--install-path", customPath,
"--os", "linux",
"--arch", "x64",
"--skip-extension",
"--skip-path",
"--keep-archive",
"--verbose");
result.EnsureSuccessful();
Assert.Contains(customPath, result.Output);
Assert.Contains("[DRY RUN]", result.Output);
}
[Fact]
public async Task InvalidPRNumber_NonNumeric_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("abc", "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
}
[Fact]
public async Task InvalidPRNumber_Zero_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("0", "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
}
[Fact]
public async Task InvalidPRNumber_Negative_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("-1", "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
}
[Fact]
public async Task UnrecognizedOptionAsFirstArg_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
}
[Fact]
public async Task UnknownFlag_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--nonexistent-flag", "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Unknown option", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AspireRepoEnvVar_IsUsedInDryRun()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
cmd.WithEnvironmentVariable("ASPIRE_REPO", "my-org/my-aspire");
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--verbose");
result.EnsureSuccessful();
Assert.Contains("my-org/my-aspire", result.Output);
}
[Fact]
public async Task DryRun_ShowsArtifactNameWithRid()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"12345", "--dry-run", "--skip-path", "--verbose",
"--os", "linux", "--arch", "x64");
result.EnsureSuccessful();
Assert.Contains("cli-native-archives", result.Output);
}
[Fact]
public async Task DryRun_ShowsDefaultInstallPath()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path");
result.EnsureSuccessful();
Assert.Contains(".aspire", result.Output);
}
[Fact]
public async Task DryRun_ShowsNugetHivePath()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--verbose");
result.EnsureSuccessful();
Assert.Contains("hive", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task HiveOnly_SkipsCLIDownload()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("12345", "--dry-run", "--skip-path", "--hive-only", "--verbose");
result.EnsureSuccessful();
Assert.Contains("Skipping CLI download", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task LocalDir_DryRun_WithGitHubRunIdEnvSet_UsesLocalHiveLabel()
{
// Regression guard: the GITHUB_RUN_ID env var must NOT influence the hive label
// when --local-dir is used. Without this test, re-introducing a GITHUB_RUN_ID
// branch would produce "run-99999" silently instead of the expected "local"
// hive label.
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var localDir = Path.Combine(env.TempDirectory, "local-artifacts");
Directory.CreateDirectory(localDir);
await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-local.1");
// Inject GITHUB_RUN_ID only into the launched process — not the test process environment.
cmd.WithEnvironmentVariable("GITHUB_RUN_ID", "99999");
var result = await cmd.ExecuteAsync(
"--local-dir", localDir,
"--hive-only",
"--dry-run");
result.EnsureSuccessful();
Assert.Contains("Using hive label: local", result.Output, StringComparison.Ordinal);
Assert.DoesNotContain("run-99999", result.Output, StringComparison.OrdinalIgnoreCase);
}
// PR-route CLI binary lands at <prefix>/dogfood/pr-<N>/bin so PR installs
// do not collide with the script-route prefix (<prefix>/bin) or with other PR installs.
// Under --dry-run the script emits the absolute install path via a
// "DRYRUN: would install Aspire CLI binary to: <path>" message on stdout;
// this test parses that message to verify the resolved install path.
[Fact]
public async Task DryRun_PRRoute_CliInstallPath_IsUnderDogfoodPrN()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999", "--dry-run", "--skip-path");
result.EnsureSuccessful();
var expectedBinaryPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin", "aspire");
Assert.Contains($"DRYRUN: would install Aspire CLI binary to: {expectedBinaryPath}", result.Output);
var scriptRouteBin = Path.Combine(env.MockHome, ".aspire", "bin", "aspire");
Assert.DoesNotContain($"DRYRUN: would install Aspire CLI binary to: {scriptRouteBin}", result.Output);
}
// Under --dry-run the PR-route script must NOT write the source=pr sidecar
// at <prefix>/dogfood/pr-<N>/.aspire-install.json. The describe-but-do-not-do
// contract requires the script to print a DRYRUN message naming the path it
// would write, then return without touching the filesystem. Positive sidecar
// content (the source field) for the PR route is covered by end-to-end
// install runs, not at this unit-test layer.
[Fact]
public async Task DryRun_PRRoute_DoesNotWriteSidecar_AndAnnouncesPath()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999", "--dry-run", "--skip-path");
result.EnsureSuccessful();
var sidecarPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin", ".aspire-install.json");
Assert.Contains($"DRYRUN: would write route sidecar to: {sidecarPath}", result.Output);
Assert.False(
File.Exists(sidecarPath),
$"Expected no sidecar to be written under --dry-run, but found one at {sidecarPath}");
}
// PR-route install prints the PATH-activation hint to stdout so users
// know how to add <prefix>/dogfood/pr-<N>/bin to their shell profile.
//
// Hint must carry the literal "$HOME" form, not the pre-expanded absolute path,
// so the profile line is portable across users.
[Fact]
public async Task DryRun_PRRoute_PrintsPathHintToStdout()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999", "--dry-run", "--skip-path");
result.EnsureSuccessful();
var hintLine = ExtractShellProfileHintLine(result.Output);
Assert.Contains("export PATH=", hintLine);
Assert.Contains(Path.Combine("dogfood", "pr-99999", "bin"), hintLine);
Assert.Contains("$HOME/.aspire/dogfood/pr-99999/bin", hintLine);
Assert.DoesNotContain(env.MockHome, hintLine);
}
[Fact]
public async Task DryRun_PRRoute_PrintsPathHintWithAbsoluteInstallPath()
{
using var env = new TestEnvironment();
var customPath = Path.Combine(env.TempDirectory, "custom-prefix");
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999", "--dry-run", "--skip-path", "--install-path", customPath);
result.EnsureSuccessful();
var hintLine = ExtractShellProfileHintLine(result.Output);
Assert.Contains($"{customPath}/dogfood/pr-99999/bin", hintLine);
Assert.DoesNotContain("$HOME", hintLine);
}
private static string ExtractShellProfileHintLine(string output)
{
foreach (var rawLine in output.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
if (line.Contains("Add to your shell profile:", StringComparison.Ordinal))
{
return line;
}
}
Assert.Fail($"Expected an 'Add to your shell profile:' line in output, but none was emitted. Output was:\n{output}");
return string.Empty;
}
// PR-route hive location is unchanged at <prefix>/hives/pr-<N>/packages.
[Fact]
public async Task DryRun_PRRoute_HiveLocation_IsUnchanged()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999", "--dry-run", "--skip-path", "--verbose");
result.EnsureSuccessful();
var expectedHive = Path.Combine("hives", "pr-99999", "packages");
Assert.Contains(expectedHive, result.Output);
}
// The PR-route script must not mutate route sidecars under --dry-run.
// Companion to DryRun_PRRoute_DoesNotWriteSidecar_AndAnnouncesPath; kept as a
// separate test method to keep dry-run sidecar-absence coverage visible in
// test inventories alongside the other dry-run guards.
[Fact]
public async Task DryRun_PRRoute_DoesNotWriteSidecar_AnyContentRegression()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999", "--dry-run", "--skip-path");
result.EnsureSuccessful();
var sidecarPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999", "bin", ".aspire-install.json");
Assert.False(
File.Exists(sidecarPath),
$"Expected no sidecar to be written under --dry-run, but found one at {sidecarPath}");
}
// Under --dry-run no global aspire.config.json is materialized.
[Fact]
public async Task DryRun_PRRoute_DoesNotCreateGlobalAspireConfigJson()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999", "--dry-run", "--skip-path");
result.EnsureSuccessful();
var globalConfig = Path.Combine(env.MockHome, ".aspire", "aspire.config.json");
Assert.False(File.Exists(globalConfig), $"Unexpected global config at {globalConfig}");
}
// Spec: --local-dir installs are unmanaged. The CLI artifacts come from a directory
// the user already has — there is no self-update path. The script MUST therefore NOT
// write a sidecar for the install: a sidecar would falsely advertise a managed
// source. With no sidecar, downstream commands (e.g. `aspire update --self`) refuse
// to assume any update channel.
[Fact]
public async Task DryRun_LocalDir_DoesNotWriteSidecar()
{
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(s_scriptPath, env, _testOutput);
var localDir = Path.Combine(env.TempDirectory, "local-artifacts");
Directory.CreateDirectory(localDir);
await FakeArchiveHelper.CreateFakeNupkgAsync(localDir, "Aspire.Cli", "13.3.0-preview.1.12345.1");
// The CLI archive isn't unpacked under --dry-run, but install_from_local_dir requires
// exactly one matching archive to be discoverable for the configured RID.
var rid = OperatingSystem.IsMacOS() ? (System.Runtime.InteropServices.RuntimeInformation.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "osx-arm64" : "osx-x64")
: OperatingSystem.IsLinux() ? (System.Runtime.InteropServices.RuntimeInformation.OSArchitecture == System.Runtime.InteropServices.Architecture.Arm64 ? "linux-arm64" : "linux-x64")
: "win-x64";
await FakeArchiveHelper.CreateFakeArchiveAsync(localDir, rid);
var result = await cmd.ExecuteAsync(
"--local-dir", localDir,
"--hive-label", "test-hive",
"--skip-path",
"--dry-run");
result.EnsureSuccessful();
// The new canonical sidecar position is <prefix>/bin/.aspire-install.json.
// --local-dir without a PR number is an unmanaged install, so the script must
// not write a sidecar; the resolver should return Unknown for these installs.
var binSidecar = Path.Combine(env.MockHome, ".aspire", "bin", ".aspire-install.json");
Assert.False(File.Exists(binSidecar), $"--local-dir install must not write sidecar at {binSidecar} (unmanaged route).");
// Defensive: walk the .aspire root and assert no sidecar landed at any depth.
var aspireRoot = Path.Combine(env.MockHome, ".aspire");
if (Directory.Exists(aspireRoot))
{
var anySidecar = Directory.GetFiles(aspireRoot, ".aspire-install.json", SearchOption.AllDirectories);
Assert.Empty(anySidecar);
}
}
// PR_NUMBER input validation — empty string. The first positional arg must be a
// valid PR number, --run-id, or --local-dir. An empty string is none of those.
[Fact]
public async Task EmptyPRNumber_ReturnsError_AndCreatesNoFiles()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("", "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
AssertNoDogfoodInstall(env.MockHome);
}
// Very large PR number above int.MaxValue. Bash regex ^[1-9][0-9]*$ accepts
// any digit-only string so the script proceeds. Documented behavior: there is no upper
// bound on PR_NUMBER bash-side; the path segment is constructed safely (digits only).
// Under --dry-run the script announces the sidecar path it would write (but does not
// write it); this test parses that announcement to verify the resolved path.
[Fact]
public async Task VeryLargePRNumber_AcceptedByScript_DryRunAnnouncesSidecarPath()
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync("99999999999", "--dry-run", "--skip-path");
result.EnsureSuccessful();
var sidecarPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-99999999999", "bin", ".aspire-install.json");
Assert.Contains($"DRYRUN: would write route sidecar to: {sidecarPath}", result.Output);
Assert.False(
File.Exists(sidecarPath),
$"Expected no sidecar to be written under --dry-run, but found one at {sidecarPath}");
}
// Path-traversal / command-injection in PR_NUMBER must be rejected at
// parse time so it never reaches the path-construction code. The regex ^[1-9][0-9]*$ is
// the gate; this test verifies the gate holds and no files leak under <prefix>/dogfood.
[Theory]
[InlineData("../etc")]
[InlineData("../../tmp")]
[InlineData("..")]
[InlineData("12345; rm -rf /tmp")]
[InlineData("12345 hello")]
[InlineData("12345|cat")]
[InlineData("12345&true")]
[InlineData("12345`whoami`")]
[InlineData("$(whoami)")]
public async Task SpecialCharsPRNumber_Rejected_AndCreatesNoFiles(string pr)
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(pr, "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
AssertNoDogfoodInstall(env.MockHome);
}
// Without the PR_NUMBER regex, "-1" would land in dogfood/pr--1/ and
// "00001"/"0123" would all collapse onto dogfood/pr-1/.
[Theory]
[InlineData("0")]
[InlineData("-1")]
[InlineData("00001")]
[InlineData("0123")]
public async Task LeadingZeroOrNegativePRNumber_Rejected_AndCreatesNoFiles(string pr)
{
using var env = new TestEnvironment();
using var cmd = await CreateCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(pr, "--dry-run", "--skip-path");
Assert.NotEqual(0, result.ExitCode);
AssertNoDogfoodInstall(env.MockHome);
}
private static void AssertNoDogfoodInstall(string mockHome)
{
var dogfoodRoot = Path.Combine(mockHome, ".aspire", "dogfood");
if (Directory.Exists(dogfoodRoot))
{
var leaks = Directory.GetFileSystemEntries(dogfoodRoot, "*", SearchOption.AllDirectories);
Assert.True(leaks.Length == 0, $"Unexpected files under {dogfoodRoot}: {string.Join(", ", leaks)}");
}
}
}