diff --git a/DevProxy.Integration.Tests/ProcessSmokeTests.cs b/DevProxy.Integration.Tests/ProcessSmokeTests.cs index 02e7f1aa..8c6e8c85 100644 --- a/DevProxy.Integration.Tests/ProcessSmokeTests.cs +++ b/DevProxy.Integration.Tests/ProcessSmokeTests.cs @@ -3,7 +3,11 @@ // See the LICENSE file in the project root for more information. using System.Diagnostics; +using System.Collections.Concurrent; using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Nodes; using Xunit; namespace DevProxy.Integration.Tests; @@ -27,6 +31,335 @@ public sealed class ProcessSmokeTests { private static readonly TimeSpan s_startupTimeout = TimeSpan.FromSeconds(90); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Startup_EnvironmentOverrides_PreserveTildeConfigPathAndApplyBeforeAnnouncement(bool useCommandLine) + { + var host = Path.Combine(Path.GetDirectoryName(LocateHostDll())!, + OperatingSystem.IsWindows() ? "devproxy.exe" : "devproxy"); + Assert.True(File.Exists(host), "Build DevProxy before running process smoke tests."); + + var workDir = Directory.CreateTempSubdirectory("devproxy-startup-env-"); + var managementDir = workDir.CreateSubdirectory("management"); + var overrideHome = workDir.CreateSubdirectory("override-home"); + using var inheritedProxy = new TcpListener(IPAddress.Loopback, 0); + using var effectiveProxy = new TcpListener(IPAddress.Loopback, 0); + inheritedProxy.Start(); + effectiveProxy.Start(); + var inheritedProxyUrl = $"http://127.0.0.1:{((IPEndPoint)inheritedProxy.LocalEndpoint).Port}"; + var effectiveProxyUrl = $"http://127.0.0.1:{((IPEndPoint)effectiveProxy.LocalEndpoint).Port}"; + var inheritedEnvironment = new Dictionary + { + ["HTTP_PROXY"] = inheritedProxyUrl, + ["HTTPS_PROXY"] = inheritedProxyUrl, + ["ALL_PROXY"] = inheritedProxyUrl, + ["NO_PROXY"] = "", + ["http_proxy"] = inheritedProxyUrl, + ["https_proxy"] = inheritedProxyUrl, + ["all_proxy"] = inheritedProxyUrl, + ["no_proxy"] = "", + }; + var overrides = new Dictionary + { + ["HOME"] = overrideHome.FullName, + ["http_proxy"] = effectiveProxyUrl, + ["https_proxy"] = effectiveProxyUrl, + ["all_proxy"] = effectiveProxyUrl, + ["no_proxy"] = "", + }; + int? pid = null; + string? logFile = null; + using var proxyTimeout = new CancellationTokenSource(s_startupTimeout); + var requests = new ConcurrentQueue(); + var inheritedRequests = new ConcurrentQueue(); + var proxyRequests = RejectProxyRequestsAsync(effectiveProxy, requests, proxyTimeout.Token); + var inheritedProxyRequests = RejectProxyRequestsAsync(inheritedProxy, inheritedRequests, proxyTimeout.Token); + try + { + var config = JsonNode.Parse(ConfigFile)!.AsObject(); + config.Remove("$schema"); + config["installCert"] = false; + config["noFirstRun"] = true; + config["validateSchemas"] = false; + config["mocksPlugin"]!["mocksFile"] = Path.Combine(workDir.FullName, "mocks.json"); + var mocks = JsonNode.Parse(MocksFile)!.AsObject(); + mocks.Remove("$schema"); + await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "mocks.json"), mocks.ToJsonString()); + await File.WriteAllTextAsync(Path.Combine(managementDir.FullName, "devproxyrc.json"), config.ToJsonString()); + if (!useCommandLine) + { + config["env"] = JsonSerializer.SerializeToNode(overrides); + } + var configPath = Path.Combine(workDir.FullName, "devproxyrc.json"); + await File.WriteAllTextAsync(configPath, config.ToJsonString()); + var tildePath = "~/" + Path.GetRelativePath( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), configPath); + var args = new List + { + "--detach", "--as-system-proxy", "false", "--port", "0", "--api-port", "0", + "--install-cert", "false", "--no-first-run", "--output", "json", "-c", tildePath, + }; + if (useCommandLine) + { + args.Add("--env"); + args.AddRange(overrides.Select(variable => $"{variable.Key}={variable.Value}")); + } + + var startup = await RunCommandAsync(host, workDir.FullName, args, inheritedEnvironment); + Assert.Equal(0, startup.ExitCode); + var started = GetResultData(startup.Output); + pid = started.GetProperty("pid").GetInt32(); + logFile = started.GetProperty("logFile").GetString(); + + Assert.Contains("CONNECT aka.ms:443 HTTP/1.1", requests); + Assert.Empty(inheritedRequests); + + var status = await RunCommandAsync(host, managementDir.FullName, + ["status", "--pid", pid.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), "--output", "json"]); + Assert.Equal(0, status.ExitCode); + var statusData = GetResultData(status.Output); + Assert.Equal(configPath, statusData.GetProperty("configFile").GetString()); + Assert.Equal("available", statusData.GetProperty("apiStatus").GetString()); + + if (!useCommandLine) + { + Assert.NotNull(logFile); + Assert.DoesNotContain("HTTP proxy settings are cached", await File.ReadAllTextAsync(logFile), StringComparison.Ordinal); + config["env"]!.AsObject().Remove("https_proxy"); + config["env"]!["no_proxy"] = "api.contoso.local"; + await Task.Delay(600); + await File.WriteAllTextAsync(configPath, config.ToJsonString()); + + using var reloadTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + string logs; + do + { + await Task.Delay(100, reloadTimeout.Token); + logs = await File.ReadAllTextAsync(logFile, reloadTimeout.Token); + } while (!logs.Contains("HTTP proxy settings are cached", StringComparison.Ordinal)); + + var warning = logs.Split('\n').Single(line => line.Contains("HTTP proxy settings are cached", StringComparison.Ordinal)); + Assert.Contains(OperatingSystem.IsWindows() ? "HTTPS_PROXY" : "https_proxy", warning, StringComparison.Ordinal); + Assert.Contains(OperatingSystem.IsWindows() ? "NO_PROXY" : "no_proxy", warning, StringComparison.Ordinal); + Assert.Contains("Stop Dev Proxy and start it again", warning, StringComparison.Ordinal); + Assert.DoesNotContain(inheritedProxyUrl, warning, StringComparison.Ordinal); + Assert.DoesNotContain(effectiveProxyUrl, warning, StringComparison.Ordinal); + + while (true) + { + await Task.Delay(100, reloadTimeout.Token); + var reloadedStatus = await RunCommandAsync(host, managementDir.FullName, + ["status", "--pid", pid.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), "--output", "json"]); + if (reloadedStatus.ExitCode == 0) + { + var data = GetResultData(reloadedStatus.Output); + if (data.GetProperty("apiStatus").GetString() == "available" && + data.GetProperty("startedAt").GetString() != statusData.GetProperty("startedAt").GetString()) + { + break; + } + } + } + } + } + finally + { + try + { + if (pid.HasValue) + { + var stopped = await RunCommandAsync(host, managementDir.FullName, + ["stop", "--pid", pid.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)]); + Assert.Equal(0, stopped.ExitCode); + } + } + finally + { + await proxyTimeout.CancelAsync(); + try + { + await Task.WhenAll(proxyRequests, inheritedProxyRequests); + } + catch (OperationCanceledException) when (proxyTimeout.IsCancellationRequested) + { + } + if (logFile is not null) + { + File.Delete(logFile); + } + workDir.Delete(recursive: true); + } + } + } + + private static async Task RejectProxyRequestsAsync( + TcpListener listener, ConcurrentQueue requests, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + using var connection = await listener.AcceptTcpClientAsync(cancellationToken); + using var reader = new StreamReader(connection.GetStream(), leaveOpen: true); + var request = await reader.ReadLineAsync(cancellationToken); + if (request is not null) + { + requests.Enqueue(request); + } + await connection.GetStream().WriteAsync( + "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"u8.ToArray(), + cancellationToken); + } + } + + [Theory] + [InlineData("text", "cli")] + [InlineData("json", "cli")] + [InlineData("text", "config")] + [InlineData("json", "config")] + [InlineData("text", null)] + [InlineData("json", null)] + public async Task Status_HonorsInstanceTokenSuppression_ExplicitRetrievalRemainsAvailable(string output, string? ciSource) + { + var host = Path.Combine(Path.GetDirectoryName(LocateHostDll())!, + OperatingSystem.IsWindows() ? "devproxy.exe" : "devproxy"); + Assert.True(File.Exists(host), "Build DevProxy before running process smoke tests."); + + var workDir = Directory.CreateTempSubdirectory("devproxy-status-"); + var managementDir = workDir.CreateSubdirectory("management"); + int? pid = null; + string? logFile = null; + try + { + var config = JsonNode.Parse(ConfigFile)!.AsObject(); + config["installCert"] = false; + config["noFirstRun"] = true; + config["validateSchemas"] = false; + config["mocksPlugin"]!["mocksFile"] = Path.Combine(workDir.FullName, "mocks.json"); + await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "mocks.json"), MocksFile); + await File.WriteAllTextAsync(Path.Combine(managementDir.FullName, "devproxyrc.json"), config.ToJsonString()); + if (ciSource == "config") + { + config["env"] = new JsonObject { ["CI"] = "1" }; + } + await File.WriteAllTextAsync(Path.Combine(workDir.FullName, "devproxyrc.json"), config.ToJsonString()); + + var args = new List + { + "--detach", "--as-system-proxy", "false", "--port", "0", "--api-port", "0", + "--install-cert", "false", "--no-first-run", "--output", "json", + }; + if (ciSource == "cli") + { + args.AddRange(["--env", "CI=1"]); + } + var startup = await RunCommandAsync(host, workDir.FullName, args); + Assert.Equal(0, startup.ExitCode); + var started = GetResultData(startup.Output); + pid = started.GetProperty("pid").GetInt32(); + logFile = started.GetProperty("logFile").GetString(); + + var tokenResult = await RunCommandAsync(host, managementDir.FullName, + ["api", "token", "--pid", pid.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)]); + Assert.Equal(0, tokenResult.ExitCode); + var token = tokenResult.Output.Split('\n') + .Select(line => line.Trim()) + .Single(line => line.Length == 64 && line.All(Uri.IsHexDigit)); + + var status = await RunCommandAsync(host, managementDir.FullName, + ["status", "--pid", pid.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), "--output", output]); + Assert.Equal(0, status.ExitCode); + var shouldDisplayToken = ciSource is null; + Assert.Equal(shouldDisplayToken, status.Output.Contains(token, StringComparison.Ordinal)); + if (output == "json") + { + var data = GetResultData(status.Output); + Assert.Equal("available", data.GetProperty("apiStatus").GetString()); + Assert.Equal(shouldDisplayToken, data.TryGetProperty("token", out _)); + } + else + { + Assert.Contains("Dev Proxy is running.", status.Output, StringComparison.Ordinal); + Assert.Equal(shouldDisplayToken, status.Output.Contains("API token:", StringComparison.Ordinal)); + } + } + finally + { + try + { + if (pid.HasValue) + { + var stopped = await RunCommandAsync(host, managementDir.FullName, + ["stop", "--pid", pid.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)]); + Assert.Equal(0, stopped.ExitCode); + } + } + finally + { + if (logFile is not null) + { + File.Delete(logFile); + } + workDir.Delete(recursive: true); + } + } + } + + private static JsonElement GetResultData(string output) + { + foreach (var line in output.Split('\n').Where(line => line.StartsWith('{'))) + { + using var json = JsonDocument.Parse(line); + if (json.RootElement.GetProperty("type").GetString() == "result") + { + return json.RootElement.GetProperty("data").Clone(); + } + } + throw new InvalidOperationException("Command did not emit a JSON result."); + } + + private static async Task<(int ExitCode, string Output)> RunCommandAsync( + string host, string workDir, IEnumerable args, IReadOnlyDictionary? environment = null) + { + var startInfo = new ProcessStartInfo(host) + { + WorkingDirectory = workDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.Environment.Remove("CI"); + if (environment is not null) + { + foreach (var (name, value) in environment) + { + startInfo.Environment[name] = value; + } + } + foreach (var arg in args) + { + startInfo.ArgumentList.Add(arg); + } + using var process = new Process { StartInfo = startInfo }; + Assert.True(process.Start()); + using var timeout = new CancellationTokenSource(s_startupTimeout); + var output = process.StandardOutput.ReadToEndAsync(timeout.Token); + var error = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.WaitForExitAsync(timeout.Token); + await Task.WhenAll(output, error); + return (process.ExitCode, await output); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + } + [Fact] public async Task Host_LoadsMockPluginFromConfig_AndServesMock() { @@ -178,7 +511,7 @@ private static string LocateHostDll() var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { - var candidate = Path.Combine(dir.FullName, "DevProxy", "bin", config, "net10.0", "DevProxy.dll"); + var candidate = Path.Combine(dir.FullName, "DevProxy", "bin", config, "net10.0", "devproxy.dll"); if (File.Exists(candidate)) { return candidate; @@ -188,7 +521,7 @@ private static string LocateHostDll() } // Fall back to a path relative to the repo root guess so the assert message is useful. - return Path.Combine(AppContext.BaseDirectory, "DevProxy.dll"); + return Path.Combine(AppContext.BaseDirectory, "devproxy.dll"); } private const string ConfigFile = """ diff --git a/DevProxy.Tests/DetachedTokenPolicyTests.cs b/DevProxy.Tests/DetachedTokenPolicyTests.cs new file mode 100644 index 00000000..53dd9da1 --- /dev/null +++ b/DevProxy.Tests/DetachedTokenPolicyTests.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.State; +using System.Text.Json; +using Xunit; + +namespace DevProxy.Tests; + +public class DetachedTokenPolicyTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ShouldDisplayTokenFor_HonorsPersistedChildPolicy(bool suppressTokenOutput) + { + var state = new ProxyInstanceState { SuppressTokenOutput = suppressTokenOutput }; + var json = JsonSerializer.Serialize(state); + var persisted = JsonSerializer.Deserialize(json); + + Assert.NotNull(persisted); + Assert.Equal(suppressTokenOutput, persisted.SuppressTokenOutput); + Assert.Equal(ApiSecurity.ShouldDisplayToken && !suppressTokenOutput, + ApiSecurity.ShouldDisplayTokenFor(persisted)); + } + + [Fact] + public void ShouldDisplayTokenFor_LegacyState_PreservesParentPolicy() + { + var state = JsonSerializer.Deserialize("{}"); + + Assert.NotNull(state); + Assert.Equal(ApiSecurity.ShouldDisplayToken, ApiSecurity.ShouldDisplayTokenFor(state)); + } +} diff --git a/DevProxy.Tests/DevProxyCommandNoColorTests.cs b/DevProxy.Tests/DevProxyCommandNoColorTests.cs index 3596fe72..2e1f49f0 100644 --- a/DevProxy.Tests/DevProxyCommandNoColorTests.cs +++ b/DevProxy.Tests/DevProxyCommandNoColorTests.cs @@ -3,13 +3,85 @@ // See the LICENSE file in the project root for more information. using DevProxy.Commands; +using DevProxy.Proxy; +using Microsoft.Extensions.Configuration; using Xunit; namespace DevProxy.Tests; +[Collection("management-environment")] public class DevProxyCommandNoColorTests { + [Theory] + [InlineData("NO_COLOR", "1", "", false)] + [InlineData("NO_COLOR", "1", "", true)] + [InlineData("TERM", "dumb", "xterm-256color", false)] + [InlineData("TERM", "dumb", "xterm-256color", true)] + public void NoColor_AddedChangedAndRemovedEnvironmentOverride_UsesCurrentValues( + string name, string disablingValue, string enablingValue, bool inheritedDisablesColor) + { + var configuration = new ConfigurationBuilder().Build(); + using var inherited = new ProcessEnvironment(); + inherited.Apply(configuration, new Dictionary + { + ["NO_COLOR"] = "", + ["TERM"] = "xterm-256color", + }); + inherited.Apply(configuration, new Dictionary + { + [name] = inheritedDisablesColor ? disablingValue : enablingValue, + }); + Assert.Equal(inheritedDisablesColor, DevProxyCommand.NoColor); + + foreach (var value in new[] { disablingValue, enablingValue, disablingValue, null }) + { + var values = new Dictionary(); + if (value is not null) + { + values[$"env:{name}"] = value; + } + var config = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + using var environment = new ProcessEnvironment(); + environment.Apply(config, null); + Assert.Equal(value is null ? inheritedDisablesColor : value == disablingValue, DevProxyCommand.NoColor); + } + + Assert.Equal(inheritedDisablesColor, DevProxyCommand.NoColor); + } + + [Theory] + [InlineData("NO_COLOR", "", "1", true)] + [InlineData("TERM", "dumb", "xterm-256color", false)] + public void NoColor_ConfigAndCommandLineOverrides_CommandLineWinsAcrossRuns( + string name, string configValue, string commandLineValue, bool expected) + { + using var inherited = new ProcessEnvironment(); + inherited.Apply(new ConfigurationBuilder().Build(), new Dictionary + { + ["NO_COLOR"] = "", + ["TERM"] = "xterm-256color", + }); + foreach (var value in new[] { configValue, commandLineValue }) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [$"env:{name}"] = value }) + .Build(); + using var environment = new ProcessEnvironment(); + environment.Apply(configuration, new Dictionary { [name] = commandLineValue }); + Assert.Equal(expected, DevProxyCommand.NoColor); + } + } + + [Theory] + [InlineData(null, null)] + [InlineData("", "xterm-256color")] + [InlineData("1", "dumb")] + public void ShouldDisableColor_ExplicitOption_WinsOverEnvironmentValues(string? noColor, string? term) + { + Assert.True(DevProxyCommand.ShouldDisableColor(["--no-color"], noColor, term)); + } + [Theory] [InlineData(null, false)] [InlineData("", false)] diff --git a/DevProxy.Tests/EnvOptionTests.cs b/DevProxy.Tests/EnvOptionTests.cs new file mode 100644 index 00000000..cc1a9aa7 --- /dev/null +++ b/DevProxy.Tests/EnvOptionTests.cs @@ -0,0 +1,137 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Commands; +using DevProxy.Proxy; +using Microsoft.Extensions.Configuration; + +using Xunit; + +namespace DevProxy.Tests; + +public class EnvOptionTests +{ + [Fact] + public void Env_ParsesCommandLineValues() + { + var options = new DevProxyConfigOptions(); + options.ParseOptions(["--env", "FOO=bar", "-e", "URL=https://x?a=b"]); + + var env = options.Env; + + Assert.NotNull(env); + Assert.Equal("bar", env["FOO"]); + Assert.Equal("https://x?a=b", env["URL"]); + } + + [Fact] + public void Env_NotSpecified_ReturnsNull() + { + var options = new DevProxyConfigOptions(); + options.ParseOptions(["--port", "0"]); + + Assert.Null(options.Env); + } + + [Fact] + public void Apply_SetsConfigAndCommandLineValues_CommandLineWins() + { + var prefix = $"DEVPROXY_TEST_{Guid.NewGuid():N}_"; + var fromConfig = prefix + "CONFIG"; + var overridden = prefix + "OVERRIDDEN"; + var fromCli = prefix + "CLI"; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"env:{fromConfig}"] = "config", + [$"env:{overridden}"] = "config", + }) + .Build(); + + try + { + using var environment = new ProcessEnvironment(); + environment.Apply(configuration, new Dictionary + { + [overridden] = "cli", + [fromCli] = "cli", + }); + + Assert.Equal("config", Environment.GetEnvironmentVariable(fromConfig)); + Assert.Equal("cli", Environment.GetEnvironmentVariable(overridden)); + Assert.Equal("cli", Environment.GetEnvironmentVariable(fromCli)); + } + finally + { + Environment.SetEnvironmentVariable(fromConfig, null); + Environment.SetEnvironmentVariable(overridden, null); + Environment.SetEnvironmentVariable(fromCli, null); + } + } + + [Theory] + [InlineData(null)] + [InlineData("inherited")] + public void Dispose_BeforeReload_RestoresRemovedOverridesAndPreservesCommandLineValues(string? inherited) + { + var prefix = $"DEVPROXY_TEST_{Guid.NewGuid():N}_"; + var removed = prefix + "REMOVED"; + var overridden = prefix + "OVERRIDDEN"; + var cli = new Dictionary { [overridden] = "cli" }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"env:{removed}"] = "config", + [$"env:{overridden}"] = "config", + }) + .Build(); + + Environment.SetEnvironmentVariable(removed, inherited); + try + { + using (var environment = new ProcessEnvironment()) + { + environment.Apply(configuration, cli); + Assert.Equal("config", Environment.GetEnvironmentVariable(removed)); + Assert.Equal("cli", Environment.GetEnvironmentVariable(overridden)); + } + + var reloadedConfiguration = new ConfigurationBuilder().Build(); + using (var environment = new ProcessEnvironment()) + { + environment.Apply(reloadedConfiguration, cli); + Assert.Equal(inherited, Environment.GetEnvironmentVariable(removed)); + Assert.Equal("cli", Environment.GetEnvironmentVariable(overridden)); + } + + Assert.Equal(inherited, Environment.GetEnvironmentVariable(removed)); + Assert.Null(Environment.GetEnvironmentVariable(overridden)); + } + finally + { + Environment.SetEnvironmentVariable(removed, null); + Environment.SetEnvironmentVariable(overridden, null); + } + } + + [Fact] + public void Dispose_WhenApplyingOverridesFails_RestoresEarlierOverrides() + { + var name = $"DEVPROXY_TEST_{Guid.NewGuid():N}"; + var configuration = new ConfigurationBuilder().Build(); + + Assert.Throws(() => + { + using var environment = new ProcessEnvironment(); + environment.Apply(configuration, new Dictionary + { + [name] = "config", + ["invalid=name"] = "invalid", + }); + }); + + Assert.Null(Environment.GetEnvironmentVariable(name)); + } +} diff --git a/DevProxy.Tests/HttpProxyEnvironmentTests.cs b/DevProxy.Tests/HttpProxyEnvironmentTests.cs new file mode 100644 index 00000000..bffd8a8e --- /dev/null +++ b/DevProxy.Tests/HttpProxyEnvironmentTests.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Proxy; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace DevProxy.Tests; + +[Collection("management-environment")] +public class HttpProxyEnvironmentTests +{ + [Theory] + [InlineData("HTTP_PROXY")] + [InlineData("HTTPS_PROXY")] + [InlineData("ALL_PROXY")] + [InlineData("NO_PROXY")] + [InlineData("http_proxy")] + [InlineData("https_proxy")] + [InlineData("all_proxy")] + [InlineData("no_proxy")] + public void GetChangedVariables_ChangedOrRemovedOverride_ReportsNameUntilInitialValueRestored(string name) + { + var configuration = new ConfigurationBuilder().Build(); + using var environment = new ProcessEnvironment(); + environment.Apply(configuration, new Dictionary { [name] = "initial" }); + var monitor = new HttpProxyEnvironment(); + Assert.Empty(monitor.GetChangedVariables()); + + Environment.SetEnvironmentVariable(name, "changed"); + var expectedName = OperatingSystem.IsWindows() ? name.ToUpperInvariant() : name; + Assert.Equal([expectedName], monitor.GetChangedVariables()); + Assert.Equal([expectedName], monitor.GetChangedVariables()); + + Environment.SetEnvironmentVariable(name, null); + Assert.Equal([expectedName], monitor.GetChangedVariables()); + + Environment.SetEnvironmentVariable(name, "initial"); + Assert.Empty(monitor.GetChangedVariables()); + } + + [Fact] + public void GetChangedVariables_ConfigChangesShadowedByCommandLine_DoesNotReportChange() + { + var monitor = new HttpProxyEnvironment(); + var cli = new Dictionary { ["HTTPS_PROXY"] = "cli" }; + foreach (var value in new[] { "config", "changed" }) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["env:HTTPS_PROXY"] = value }) + .Build(); + using var environment = new ProcessEnvironment(); + environment.Apply(configuration, cli); + Assert.Empty(monitor.GetChangedVariables()); + } + } +} diff --git a/DevProxy.Tests/StateManagerEnvironmentTests.cs b/DevProxy.Tests/StateManagerEnvironmentTests.cs new file mode 100644 index 00000000..ee059afb --- /dev/null +++ b/DevProxy.Tests/StateManagerEnvironmentTests.cs @@ -0,0 +1,98 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Proxy; +using DevProxy.Commands; +using DevProxy.State; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace DevProxy.Tests; + +[CollectionDefinition("management-environment", DisableParallelization = true)] +public class ManagementEnvironmentDefinition; + +[Collection("management-environment")] +public class StateManagerEnvironmentTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ConfigFile_AfterHomeOverride_RetainsInitiallyResolvedPath(bool stdio) + { + var options = new DevProxyConfigOptions(); + options.ParseOptions(stdio + ? ["stdio", "-c", "~/.devproxy/devproxyrc.json", "node", "app.js"] + : ["-c", "~/.devproxy/devproxyrc.json"]); + var inheritedPath = options.ConfigFile; + Assert.NotNull(inheritedPath); + Assert.True(Path.IsPathFullyQualified(inheritedPath)); + + var home = Directory.CreateTempSubdirectory("devproxy-config-home-"); + try + { + using var environment = new ProcessEnvironment(); + environment.Apply(new ConfigurationBuilder().Build(), new Dictionary + { + ["HOME"] = home.FullName, + }); + Assert.Equal(inheritedPath, options.ConfigFile); + } + finally + { + home.Delete(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ManagementPaths_ConfigOrCommandLineEnvironmentOverrides_RetainInheritedLocation(bool useCommandLine) + { + var inheritedFolder = StateManager.GetConfigFolder(); + var overridesFolder = Directory.CreateTempSubdirectory("devproxy-env-"); + var variables = new Dictionary + { + ["HOME"] = overridesFolder.FullName, + ["XDG_CONFIG_HOME"] = overridesFolder.FullName, + ["LOCALAPPDATA"] = overridesFolder.FullName, + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(useCommandLine + ? [] + : variables.Select(variable => + new KeyValuePair($"env:{variable.Key}", variable.Value))) + .Build(); + + try + { + using (var environment = new ProcessEnvironment()) + { + environment.Apply(configuration, useCommandLine ? variables : null); + + foreach (var (name, value) in variables) + { + Assert.Equal(value, Environment.GetEnvironmentVariable(name)); + } + AssertManagementPaths(inheritedFolder); + } + + AssertManagementPaths(inheritedFolder); + } + finally + { + overridesFolder.Delete(); + } + } + + private static void AssertManagementPaths(string inheritedFolder) + { + Assert.Equal(inheritedFolder, StateManager.GetConfigFolder()); + Assert.Equal(Path.Combine(inheritedFolder, "state-1234.json"), StateManager.GetInstanceStateFilePath(1234)); + Assert.Equal(Path.Combine(inheritedFolder, "state.json"), StateManager.GetStateFilePath()); + Assert.Equal(Path.Combine(inheritedFolder, "credentials", "api-1234.token"), ApiSecurity.GetTokenFilePath(1234)); + Assert.Equal(Path.Combine(inheritedFolder, "logs"), StateManager.GetLogsFolder()); + Assert.Equal(StateManager.GetLogsFolder(), Path.GetDirectoryName(StateManager.GenerateLogFilePath())); + } +} diff --git a/DevProxy/Announcement.cs b/DevProxy/Announcement.cs index c200b9fd..0c7e1e03 100644 --- a/DevProxy/Announcement.cs +++ b/DevProxy/Announcement.cs @@ -9,9 +9,15 @@ namespace DevProxy; static class Announcement { private static readonly Uri announcementUrl = new("https://aka.ms/devproxy/announcement"); + private static int _requested; - public static async Task ShowAsync() + public static async Task ShowOnceAsync() { + if (Interlocked.Exchange(ref _requested, 1) != 0) + { + return; + } + var announcement = await GetAsync(); if (!string.IsNullOrEmpty(announcement)) { diff --git a/DevProxy/ApiSecurity.cs b/DevProxy/ApiSecurity.cs index 065e8e72..a7f5ad00 100644 --- a/DevProxy/ApiSecurity.cs +++ b/DevProxy/ApiSecurity.cs @@ -14,6 +14,9 @@ internal static class ApiSecurity public static bool ShouldDisplayToken => Environment.GetEnvironmentVariable("CI") is null; public static string? DisplayToken => ShouldDisplayToken ? Token : null; + public static bool ShouldDisplayTokenFor(ProxyInstanceState state) => + ShouldDisplayToken && !state.SuppressTokenOutput; + public static void LogTokenOnce(ILogger logger) { if (!ShouldDisplayToken || _tokenDisplayed) diff --git a/DevProxy/Commands/DevProxyCommand.cs b/DevProxy/Commands/DevProxyCommand.cs index b08b65bb..13fb0418 100644 --- a/DevProxy/Commands/DevProxyCommand.cs +++ b/DevProxy/Commands/DevProxyCommand.cs @@ -63,7 +63,6 @@ sealed class DevProxyCommand : RootCommand private static bool _isInternalDaemonResolved; private static bool _stdioLogFilePathResolved; private static bool _detachedLogFilePathResolved; - private static bool _noColorResolved; public static bool HasGlobalOptions { @@ -222,23 +221,10 @@ public static string DetachedLogFilePath } } - public static bool NoColor - { - get - { - if (_noColorResolved) - { - return field; - } - - field = ShouldDisableColor( - Environment.GetCommandLineArgs(), - Environment.GetEnvironmentVariable("NO_COLOR"), - Environment.GetEnvironmentVariable("TERM")); - _noColorResolved = true; - return field; - } - } + public static bool NoColor => ShouldDisableColor( + Environment.GetCommandLineArgs(), + Environment.GetEnvironmentVariable("NO_COLOR"), + Environment.GetEnvironmentVariable("TERM")); // Per https://no-color.org, NO_COLOR disables color only when present and not empty. internal static bool ShouldDisableColor(string[] args, string? noColorEnv, string? termEnv) => @@ -252,6 +238,7 @@ public DevProxyCommand( IProxyConfiguration proxyConfiguration, IServiceProvider serviceProvider, UpdateNotification updateNotification, + DevProxyConfigOptions options, ILogger logger) : base($"Start Dev Proxy\n\nAPI:\n Dev Proxy exposes an authenticated REST API for runtime management.\n Use --api-port (default: {proxyConfiguration.ApiPort}) and --api-ip-address (default: 127.0.0.1).\n The API bind address is independent of --ip-address.\n Run 'devproxy status' for the API URL and token, or 'devproxy api show' for endpoints.") { _serviceProvider = serviceProvider; @@ -261,7 +248,7 @@ public DevProxyCommand( _updateNotification = updateNotification; _logger = logger; - ConfigureCommand(); + ConfigureCommand(options); } public async Task InvokeAsync(string[] args, WebApplication app) @@ -368,12 +355,12 @@ private async Task InvokeAsync(ParseResult parseResult, CancellationToken c } } - private void ConfigureCommand() + private void ConfigureCommand(DevProxyConfigOptions configOptions) { ConfigFileOption.Validators.Clear(); ConfigFileOption.Validators.Add(input => { - var filePath = ProxyUtils.ReplacePathTokens(input.Tokens[0].Value); + var filePath = configOptions.ConfigFile ?? ProxyUtils.ReplacePathTokens(input.Tokens[0].Value); if (string.IsNullOrEmpty(filePath)) { return; @@ -533,7 +520,7 @@ private void ConfigureCommand() { AllowMultipleArgumentsPerToken = true, Arity = ArgumentArity.ZeroOrMore, - Description = "Variables to set for the Dev Proxy process", + Description = "Environment variables to set for the Dev Proxy process, in name=value format. Also used to replace {name} placeholders in API spec files used by the MinimalPermissions plugins. Changes to HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, or NO_PROXY (including lowercase variants) require a full stop/start; hot reload cannot refresh cached HTTP proxy settings", HelpName = "env", }; envOption.Validators.Add(input => @@ -550,7 +537,7 @@ private void ConfigureCommand() { // Split on first '=' only var parts = envVar.Split('=', 2); - if (parts.Length != 2) + if (parts.Length != 2 || parts[0].Length == 0) { input.AddError($"Invalid environment variable format: '{envVar}'. Expected format is 'name=value'."); return; @@ -810,7 +797,8 @@ private async Task WriteInstanceStateAsync(string apiUrl) StartedAt = DateTimeOffset.UtcNow, ConfigFile = _proxyConfiguration.ConfigFile, Port = _proxyConfiguration.Port, - AsSystemProxy = _proxyConfiguration.AsSystemProxy + AsSystemProxy = _proxyConfiguration.AsSystemProxy, + SuppressTokenOutput = !ApiSecurity.ShouldDisplayToken }; await StateManager.SaveStateAsync(state); diff --git a/DevProxy/Commands/DevProxyConfigOptions.cs b/DevProxy/Commands/DevProxyConfigOptions.cs index 11173475..e8738c0d 100644 --- a/DevProxy/Commands/DevProxyConfigOptions.cs +++ b/DevProxy/Commands/DevProxyConfigOptions.cs @@ -7,16 +7,7 @@ namespace DevProxy.Commands; sealed class DevProxyConfigOptions : RootCommand { - public string? ConfigFile - { - get - { - var configFile = _parseResult?.GetValueOrDefault(DevProxyCommand.ConfigFileOptionName); - return configFile is not null ? - Path.GetFullPath(ProxyUtils.ReplacePathTokens(configFile)) : - null; - } - } + public string? ConfigFile { get; private set; } public int? ApiPort => _parseResult?.GetValueOrDefault(DevProxyCommand.ApiPortOptionName); public string? ApiIpAddress => _parseResult?.GetValueOrDefault(DevProxyCommand.ApiIpAddressOptionName); @@ -28,6 +19,29 @@ public string? ConfigFile public OutputFormat? Output => _parseResult?.GetValueOrDefault(DevProxyCommand.OutputOptionName); public LogLevel? LogLevel => _parseResult?.GetValueOrDefault(DevProxyCommand.LogLevelOptionName); + public Dictionary? Env => ParseEnv(_parseResult?.GetValueOrDefault(DevProxyCommand.EnvOptionName)); + + internal static Dictionary? ParseEnv(string[]? env) + { + if (env is null || env.Length == 0) + { + return null; + } + + var result = new Dictionary(); + foreach (var e in env) + { + // Split on first '=' only + var parts = e.Split('=', 2); + if (parts.Length == 2 && parts[0].Length > 0) + { + result[parts[0]] = parts[1]; + } + } + + return result; + } + public List? UrlsToWatch { get @@ -154,6 +168,12 @@ public DevProxyConfigOptions() Arity = ArgumentArity.Zero }; + var envOption = new Option(DevProxyCommand.EnvOptionName, "-e") + { + AllowMultipleArgumentsPerToken = true, + Arity = ArgumentArity.ZeroOrMore + }; + var options = new List