Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion DevProxy.Abstractions/Models/MockResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@ public object Clone()
return JsonSerializer.Deserialize<MockResponse>(json) ?? new MockResponse();
}

public static MockResponse FromHttpResponse(string httpResponse, ILogger logger)
public static MockResponse FromHttpResponse(string httpResponse, ILogger logger) =>
FromHttpResponse(httpResponse, logger, out _);

/// <summary>
/// Parses an HTTP response, reporting recoverable errors without changing the
/// generated mock's fallback headers or body.
/// </summary>
public static MockResponse FromHttpResponse(string httpResponse, ILogger logger, out bool hasErrors)
{
ArgumentNullException.ThrowIfNull(logger);
hasErrors = false;

logger.LogTrace("{Method} called", nameof(FromHttpResponse));

Expand Down Expand Up @@ -77,6 +85,7 @@ public static MockResponse FromHttpResponse(string httpResponse, ILogger logger)
catch (JsonException ex)
{
logger.LogError(ex, "Failed to deserialize JSON body from HTTP response");
hasErrors = true;
body = bodyContents;
}
}
Expand All @@ -94,6 +103,7 @@ public static MockResponse FromHttpResponse(string httpResponse, ILogger logger)
if (headerParts.Length < 2)
{
logger.LogError($"Invalid HTTP response header format");
hasErrors = true;
continue;
}

Expand Down
3 changes: 2 additions & 1 deletion DevProxy.Abstractions/Proxy/IRootCertificateTrust.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ public interface IRootCertificateTrust
/// <summary>
/// Unconditionally installs <paramref name="rootCertificate"/> into the OS trust
/// store, bypassing the first-run/install policy. Used by the explicit
/// <c>cert ensure</c> command. Best-effort: failures are logged, not thrown.
/// <c>cert ensure</c> command. Installation failures are logged and thrown.
/// On platforms without automatic trust installation, logs manual instructions.
/// </summary>
void Trust(X509Certificate2 rootCertificate);

Expand Down
235 changes: 235 additions & 0 deletions DevProxy.Integration.Tests/CommandExitCodeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// 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 System.Diagnostics;
using System.Text.Json;
using Xunit;

namespace DevProxy.Integration.Tests;

/// <summary>
/// Spawns the real DevProxy host to verify that CLI commands return a non-zero
/// exit code when they fail, so scripts and agents can rely on it.
/// </summary>
[Collection("process-smoke")]
public sealed class CommandExitCodeTests
{
private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(90);

[Fact]
public async Task ConfigNew_Succeeds_ReturnsZero()
{
using var workDir = new TempDir();

var (exitCode, output) = await RunHostAsync(workDir.Path, "config", "new", "myconfig.json");

Assert.True(exitCode == 0, output);
Assert.True(File.Exists(Path.Combine(workDir.Path, "myconfig.json")), output);
}

[Fact]
public async Task ConfigNew_Fails_ReturnsNonZero()
{
using var workDir = new TempDir();

var (exitCode, output) = await RunHostAsync(workDir.Path, "config", "new", Path.Combine("missing-folder", "myconfig.json"));

Assert.True(exitCode != 0, output);
}

[Fact]
public async Task ConfigGet_DownloadFails_ReturnsNonZero()
{
using var workDir = new TempDir();
var configId = $"devproxy-exitcode-test-{Guid.NewGuid():N}";
// Route the GitHub request through an unreachable proxy so the download fails without network access.
var env = new Dictionary<string, string>
{
["HTTPS_PROXY"] = "http://127.0.0.1:1",
["HTTP_PROXY"] = "http://127.0.0.1:1",
["NO_PROXY"] = "",
};

try
{
var (exitCode, output) = await RunHostAsync(workDir.Path, env, "config", "get", configId);

Assert.True(exitCode != 0, output);
}
finally
{
var configsFolder = Path.Combine(DevProxy.Abstractions.Utils.ProxyUtils.DataFolder, "configs");
if (Directory.Exists(configsFolder))
{
foreach (var dir in Directory.GetDirectories(configsFolder, $"{configId}*"))
{
Directory.Delete(dir, recursive: true);
}
}
}
}

[Fact]
public async Task MocksFromHttpResponses_Succeeds_ReturnsZero()
{
using var workDir = new TempDir();
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile);
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "response.http"), "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nhello");

var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json");

Assert.True(exitCode == 0, output);
Assert.True(File.Exists(Path.Combine(workDir.Path, "mocks.json")), output);
}

[Fact]
public async Task MocksFromHttpResponses_NoMatchingFiles_ReturnsNonZero()
{
using var workDir = new TempDir();
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile);

var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json");

Assert.True(exitCode != 0, output);
}

[Fact]
public async Task MocksFromHttpResponses_InvalidFile_ReturnsNonZero()
{
using var workDir = new TempDir();
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile);
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "response.http"), "not an http response");

var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json");

Assert.True(exitCode != 0, output);
}

[Theory]
[InlineData("HTTP/1.1 200 OK\nInvalidHeader\n\nhello", "hello")]
[InlineData("HTTP/1.1 200 OK\nContent-Type: application/json\n\n{invalid}", "{invalid}")]
public async Task MocksFromHttpResponses_LoggedParseError_ReturnsNonZeroAndPreservesMocks(string response, string body)
{
using var workDir = new TempDir();
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "devproxyrc.json"), ConfigFile);
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "invalid.http"), response);
await File.WriteAllTextAsync(Path.Combine(workDir.Path, "valid.http"), "HTTP/1.1 201 Created\n\nvalid");

var (exitCode, output) = await RunHostAsync(workDir.Path, "mocks", "from-http-responses", "*.http", "--mocks-file", "mocks.json");

Assert.True(exitCode != 0, output);
using var mocks = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(workDir.Path, "mocks.json")));
var responses = mocks.RootElement.GetProperty("mocks").EnumerateArray()
.Select(mock => mock.GetProperty("response")).ToArray();
Assert.Equal(2, responses.Length);
Assert.Contains(responses, response => response.GetProperty("statusCode").GetInt32() == 200 &&
response.GetProperty("body").GetString() == body);
Assert.Contains(responses, response => response.GetProperty("statusCode").GetInt32() == 201 &&
response.GetProperty("body").GetString() == "valid");
}

private static Task<(int ExitCode, string Output)> RunHostAsync(string workDir, params string[] args) =>
RunHostAsync(workDir, new Dictionary<string, string>(), args);

private static async Task<(int ExitCode, string Output)> RunHostAsync(string workDir, Dictionary<string, string> env, params string[] args)
{
var hostDll = LocateHostDll();
Assert.True(File.Exists(hostDll), $"DevProxy host not built at {hostDll}. Run `dotnet build DevProxy` first.");

var psi = new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
};
psi.ArgumentList.Add(hostDll);
foreach (var arg in args)
{
psi.ArgumentList.Add(arg);
}
foreach (var (key, value) in env)
{
psi.Environment[key] = value;
}

using var process = new Process { StartInfo = psi };
Assert.True(process.Start(), "Failed to start DevProxy host process.");
process.StandardInput.Close();

using var cts = new CancellationTokenSource(s_timeout);
var stdout = process.StandardOutput.ReadToEndAsync(cts.Token);
var stderr = process.StandardError.ReadToEndAsync(cts.Token);
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException($"DevProxy host did not exit within {s_timeout.TotalSeconds}s.");
}

var output = $"STDOUT:\n{await stdout}\nSTDERR:\n{await stderr}";
return (process.ExitCode, output);
}

private static string LocateHostDll()
{
var config =
#if DEBUG
"Debug";
#else
"Release";
#endif
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
var candidate = Path.Combine(dir.FullName, "DevProxy", "bin", config, "net10.0", "DevProxy.dll");
if (File.Exists(candidate))
{
return candidate;
}

dir = dir.Parent;
}

return Path.Combine(AppContext.BaseDirectory, "DevProxy.dll");
}

private sealed class TempDir : IDisposable
{
private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("devproxy-exitcode-");

public string Path => _dir.FullName;

public void Dispose()
{
try
{
_dir.Delete(recursive: true);
}
catch (IOException)
{
}
}
}

private const string ConfigFile = """
{
"plugins": [
{
"name": "MockResponsePlugin",
"enabled": true,
"pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll"
}
],
"urlsToWatch": [
"https://api.contoso.local/*"
]
}
""";
}
14 changes: 9 additions & 5 deletions DevProxy.Plugins/Mocking/MockResponsePlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ private void ProcessMockResponseInternal(ProxyRequestArgs e, MockResponse matchi
Logger.LogRequest($"{matchingResponse.Response?.StatusCode ?? 200} {matchingResponse.Request?.Url}", MessageType.Mocked, new LoggingContext(e.ProxySession));
}

private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult)
private async Task<int> GenerateMocksFromHttpResponsesAsync(ParseResult parseResult)
{
Logger.LogTrace("{Method} called", nameof(GenerateMocksFromHttpResponsesAsync));

Expand All @@ -442,14 +442,14 @@ private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult)
if (string.IsNullOrEmpty(outputFilePath))
{
Logger.LogError("No output file path provided for mock responses.");
return;
return 1;
}

var httpResponseFiles = parseResult.GetValue(_httpResponseFilesArgument);
if (httpResponseFiles is null || !httpResponseFiles.Any())
{
Logger.LogError("No HTTP response files provided.");
return;
return 1;
}

var matcher = new Matcher();
Expand All @@ -459,7 +459,7 @@ private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult)
if (!matchingFiles.Any())
{
Logger.LogError("No matching HTTP response files found.");
return;
return 1;
}

if (Logger.IsEnabled(LogLevel.Information))
Expand All @@ -472,16 +472,19 @@ private async Task GenerateMocksFromHttpResponsesAsync(ParseResult parseResult)
}

var mockResponses = new List<MockResponse>();
var hasErrors = false;
foreach (var file in matchingFiles)
{
Logger.LogInformation("Processing file: {File}", Path.GetRelativePath(".", file));
try
{
mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger));
mockResponses.Add(MockResponse.FromHttpResponse(await File.ReadAllTextAsync(file), Logger, out var hasParseErrors));
hasErrors |= hasParseErrors;
}
catch (Exception ex)
{
Logger.LogError(ex, "Error processing file {File}", file);
hasErrors = true;
continue;
}
}
Expand All @@ -498,6 +501,7 @@ await File.WriteAllTextAsync(
Logger.LogInformation("Generated mock responses saved to {OutputFile}", outputFilePath);

Logger.LogTrace("Left {Method}", nameof(GenerateMocksFromHttpResponsesAsync));
return hasErrors ? 1 : 0;
Comment thread
waldekmastykarz marked this conversation as resolved.
}

private static bool HasMatchingBody(MockResponse mockResponse, IHttpRequest request)
Expand Down
Loading
Loading