Skip to content

Commit 4afbe8a

Browse files
Merge main into API Center environment variable fix
Preserve configuration-key validation tests alongside environment-variable resolution tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents 06b699e + f285544 commit 4afbe8a

27 files changed

Lines changed: 748 additions & 47 deletions

‎DevProxy.Abstractions/DevProxy.Abstractions.csproj‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
<PackageReference Include="Markdig" Version="1.4.0" />
3636
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
3737
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.13" />
38-
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" />
38+
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.13" />
3939
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
4040
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.13" />
4141
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />

‎DevProxy.Abstractions/packages.lock.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@
3535
},
3636
"Microsoft.Extensions.Configuration.Binder": {
3737
"type": "Direct",
38-
"requested": "[10.0.12, )",
39-
"resolved": "10.0.12",
40-
"contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==",
38+
"requested": "[10.0.13, )",
39+
"resolved": "10.0.13",
40+
"contentHash": "yJJl/b0+W+GEWbu+7viw6C3h2BoNkB69Vlz7s/4e+hm4mYbJqfhdcDSi9sCXJw47blsBaiQd3UDPgDtop92+ug==",
4141
"dependencies": {
42-
"Microsoft.Extensions.Configuration": "10.0.12",
43-
"Microsoft.Extensions.Configuration.Abstractions": "10.0.12"
42+
"Microsoft.Extensions.Configuration": "10.0.13",
43+
"Microsoft.Extensions.Configuration.Abstractions": "10.0.13"
4444
}
4545
},
4646
"Microsoft.Extensions.Configuration.Json": {

‎DevProxy.Integration.Tests/ApiCenterClientTests.cs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,31 @@
88

99
namespace DevProxy.Integration.Tests;
1010

11-
public class ApiCenterClientTests
11+
public sealed class ApiCenterClientTests
1212
{
1313
private const string VariableName = "DEVPROXY_TEST_APICENTER_SUBSCRIPTION_ID";
1414

15+
[Theory]
16+
[InlineData("subscriptionId")]
17+
[InlineData("resourceGroupName")]
18+
[InlineData("serviceName")]
19+
[InlineData("workspaceName")]
20+
public void Constructor_MissingSetting_ErrorUsesConfigKeyName(string missingKey)
21+
{
22+
var configuration = new ApiCenterClientConfiguration
23+
{
24+
SubscriptionId = missingKey == "subscriptionId" ? "" : "sub",
25+
ResourceGroupName = missingKey == "resourceGroupName" ? "" : "rg",
26+
ServiceName = missingKey == "serviceName" ? "" : "svc",
27+
WorkspaceName = missingKey == "workspaceName" ? "" : "default"
28+
};
29+
30+
var ex = Assert.Throws<ArgumentException>(() =>
31+
new ApiCenterClient(configuration, NullLogger<ApiCenterClient>.Instance));
32+
33+
Assert.Equal($"Specify {missingKey} in the configuration.", ex.Message);
34+
}
35+
1536
[Fact]
1637
public void Constructor_UnsetEnvironmentVariable_Throws()
1738
{

‎DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,107 @@ public async Task GenericRandomError_DynamicRetryAfterOnNon429_IsResolved()
189189
Assert.Equal(HttpStatusCode.TooManyRequests, retry.StatusCode);
190190
}
191191

192+
[Fact]
193+
public async Task GenericRandomError_BodyWithoutContentType_DefaultsToApplicationJson()
194+
{
195+
await using var origin = await FakeOrigin.StartAsync();
196+
var dir = Directory.CreateTempSubdirectory("devproxy-errors-");
197+
try
198+
{
199+
await File.WriteAllTextAsync(
200+
Path.Combine(dir.FullName, "errors.json"),
201+
$$"""
202+
{
203+
"errors": [
204+
{
205+
"request": { "url": "http://{{origin.Host}}/*" },
206+
"responses": [ { "statusCode": 500, "body": { "error": "boom" } } ]
207+
}
208+
]
209+
}
210+
""");
211+
var proxyConfig = new TestProxyConfiguration
212+
{
213+
ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
214+
};
215+
var plugin = new GenericRandomErrorPlugin(
216+
SharedHttpClient,
217+
NullLogger<GenericRandomErrorPlugin>.Instance,
218+
KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
219+
proxyConfig,
220+
PluginConfig.FromJson("""{ "rate": 100 }"""));
221+
222+
using var host = new PluginTestHost(proxyConfig);
223+
await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);
224+
225+
await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
226+
using var client = proxy.CreateHttpClient();
227+
228+
using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
229+
230+
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
231+
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
232+
}
233+
finally
234+
{
235+
dir.Delete(recursive: true);
236+
}
237+
}
238+
239+
[Fact]
240+
public async Task GenericRandomError_ExplicitContentType_IsPreserved()
241+
{
242+
await using var origin = await FakeOrigin.StartAsync();
243+
var dir = Directory.CreateTempSubdirectory("devproxy-errors-");
244+
try
245+
{
246+
await File.WriteAllTextAsync(
247+
Path.Combine(dir.FullName, "errors.json"),
248+
$$"""
249+
{
250+
"errors": [
251+
{
252+
"request": { "url": "http://{{origin.Host}}/*" },
253+
"responses": [
254+
{
255+
"statusCode": 500,
256+
"headers": [ { "name": "Content-Type", "value": "text/plain" } ],
257+
"body": "boom"
258+
}
259+
]
260+
}
261+
]
262+
}
263+
""");
264+
var proxyConfig = new TestProxyConfiguration
265+
{
266+
ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
267+
};
268+
var plugin = new GenericRandomErrorPlugin(
269+
SharedHttpClient,
270+
NullLogger<GenericRandomErrorPlugin>.Instance,
271+
KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
272+
proxyConfig,
273+
PluginConfig.FromJson("""{ "rate": 100 }"""));
274+
275+
using var host = new PluginTestHost(proxyConfig);
276+
await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);
277+
278+
await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
279+
using var client = proxy.CreateHttpClient();
280+
281+
using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
282+
283+
Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
284+
Assert.True(response.Content.Headers.TryGetValues("Content-Type", out var values));
285+
_ = Assert.Single(values);
286+
}
287+
finally
288+
{
289+
dir.Delete(recursive: true);
290+
}
291+
}
292+
192293
[Fact]
193294
public async Task Latency_AddsConfiguredDelayBeforeForwarding()
194295
{
@@ -239,6 +340,47 @@ public void Latency_DelayRangeIncludesMaxMs()
239340
Assert.InRange(LatencyPlugin.GetDelay(random, int.MaxValue - 1, int.MaxValue), int.MaxValue - 1, int.MaxValue);
240341
}
241342

343+
[Fact]
344+
public async Task Latency_InitializeFailsWhenMinMsGreaterThanMaxMs()
345+
{
346+
var config = PluginConfig.FromJson("""
347+
{ "minMs": 500, "maxMs": 100 }
348+
""");
349+
var plugin = new LatencyPlugin(
350+
SharedHttpClient,
351+
NullLogger<LatencyPlugin>.Instance,
352+
KestrelProxyHarness.BuildUrlsToWatch("localhost"),
353+
ProxyConfig,
354+
config);
355+
356+
using var host = new PluginTestHost(ProxyConfig);
357+
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
358+
() => plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None));
359+
360+
Assert.Contains("minMs (500)", ex.Message, StringComparison.Ordinal);
361+
Assert.Contains("maxMs (100)", ex.Message, StringComparison.Ordinal);
362+
Assert.False(plugin.Enabled);
363+
}
364+
365+
[Fact]
366+
public async Task Latency_InitializeSucceedsWhenMinMsEqualsMaxMs()
367+
{
368+
var config = PluginConfig.FromJson("""
369+
{ "minMs": 100, "maxMs": 100 }
370+
""");
371+
var plugin = new LatencyPlugin(
372+
SharedHttpClient,
373+
NullLogger<LatencyPlugin>.Instance,
374+
KestrelProxyHarness.BuildUrlsToWatch("localhost"),
375+
ProxyConfig,
376+
config);
377+
378+
using var host = new PluginTestHost(ProxyConfig);
379+
await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);
380+
381+
Assert.True(plugin.Enabled);
382+
}
383+
242384
[Fact]
243385
public async Task RateLimiting_ThrottlesOnceResourcesAreExhausted()
244386
{
@@ -268,6 +410,50 @@ public async Task RateLimiting_ThrottlesOnceResourcesAreExhausted()
268410
Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
269411
}
270412

413+
[Fact]
414+
public async Task RateLimiting_CustomResponseWithBody_DefaultsToApplicationJson()
415+
{
416+
await using var origin = await FakeOrigin.StartAsync();
417+
var dir = Directory.CreateTempSubdirectory("devproxy-ratelimit-");
418+
try
419+
{
420+
await File.WriteAllTextAsync(
421+
Path.Combine(dir.FullName, "rate-limit-response.json"),
422+
"""
423+
{ "statusCode": 403, "body": { "error": "quota exceeded" } }
424+
""");
425+
var proxyConfig = new TestProxyConfiguration
426+
{
427+
ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
428+
};
429+
var plugin = new RateLimitingPlugin(
430+
SharedHttpClient,
431+
NullLogger<RateLimitingPlugin>.Instance,
432+
KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
433+
proxyConfig,
434+
PluginConfig.FromJson("""
435+
{ "rateLimit": 2, "costPerRequest": 2, "resetTimeWindowSeconds": 300, "whenLimitExceeded": "Custom" }
436+
"""));
437+
438+
using var host = new PluginTestHost(proxyConfig);
439+
await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);
440+
441+
await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
442+
using var client = proxy.CreateHttpClient();
443+
444+
using var first = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
445+
using var limited = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
446+
447+
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
448+
Assert.Equal(HttpStatusCode.Forbidden, limited.StatusCode);
449+
Assert.Equal("application/json", limited.Content.Headers.ContentType?.MediaType);
450+
}
451+
finally
452+
{
453+
dir.Delete(recursive: true);
454+
}
455+
}
456+
271457
[Fact]
272458
public async Task RateLimitingPlusRetryAfter_ThrottledResponseCarriesRetryAfter()
273459
{

‎DevProxy.Integration.Tests/GenerationPluginsIntegrationTests.cs‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using DevProxy.Abstractions.Proxy.Http;
1212
using DevProxy.Abstractions.Utils;
1313
using DevProxy.Plugins.Generation;
14+
using Microsoft.Extensions.Logging;
1415
using Microsoft.Extensions.Logging.Abstractions;
1516
using Xunit;
1617

@@ -160,6 +161,49 @@ public async Task MockGenerator_WritesMockFile()
160161
}
161162
}
162163

164+
[Fact]
165+
public async Task MockGenerator_PreservesRecordedOrderForSameUrl()
166+
{
167+
var plugin = new MockGeneratorPlugin(NullLogger<MockGeneratorPlugin>.Instance, Watch);
168+
169+
// enough entries to exceed the insertion-sort threshold of an unstable sort
170+
const int count = 40;
171+
var logs = Enumerable.Range(0, count)
172+
.Select(i => TestExchange
173+
.Request("GET", i % 2 == 0 ? "https://api.contoso.com/a" : "https://api.contoso.com/b")
174+
.WithResponse(
175+
HttpStatusCode.OK,
176+
headers: [("Content-Type", "application/json")],
177+
body: $$"""{ "seq": {{i}} }""")
178+
.AsRequestLog(MessageType.InterceptedResponse))
179+
.ToList();
180+
181+
var args = Recording(logs);
182+
var dir = await InTempCwdAsync(() => plugin.AfterRecordingStopAsync(args, CancellationToken.None));
183+
try
184+
{
185+
var mockFile = dir.GetFiles("mocks-*.json").Single();
186+
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(mockFile.FullName));
187+
var mocks = doc.RootElement.GetProperty("mocks").EnumerateArray()
188+
.Select(m => (
189+
Url: m.GetProperty("request").GetProperty("url").GetString(),
190+
Seq: m.GetProperty("response").GetProperty("body").GetProperty("seq").GetInt32()))
191+
.ToArray();
192+
193+
Assert.Equal(count, mocks.Length);
194+
195+
var expected = Enumerable.Range(0, count).Where(i => i % 2 == 1)
196+
.Select(i => ("https://api.contoso.com/b", i))
197+
.Concat(Enumerable.Range(0, count).Where(i => i % 2 == 0)
198+
.Select(i => ("https://api.contoso.com/a", i)));
199+
Assert.Equal(expected, mocks.Select(m => (m.Url!, m.Seq)));
200+
}
201+
finally
202+
{
203+
dir.Delete(recursive: true);
204+
}
205+
}
206+
163207
[Fact]
164208
public async Task HttpFileGenerator_WritesHttpFile()
165209
{
@@ -217,9 +261,10 @@ public async Task OpenApiSpecGenerator_WritesSpec()
217261
[Fact]
218262
public async Task TypeSpecGenerator_WritesSpec()
219263
{
264+
var logger = new MessageCapturingLogger<TypeSpecGeneratorPlugin>();
220265
var plugin = new TypeSpecGeneratorPlugin(
221266
TestDefaults.HttpClient,
222-
NullLogger<TypeSpecGeneratorPlugin>.Instance,
267+
logger,
223268
Watch,
224269
new DisabledLanguageModelClient(),
225270
new TestProxyConfiguration(),
@@ -233,10 +278,30 @@ public async Task TypeSpecGenerator_WritesSpec()
233278
((Dictionary<string, object>)args.GlobalData[ProxyUtils.ReportsKey])[plugin.Name]);
234279
Assert.NotEmpty(report);
235280
Assert.NotEmpty(dir.GetFiles("*.tsp"));
281+
Assert.Contains(logger.Messages, m => m.StartsWith("Writing TypeSpec file to ", StringComparison.Ordinal));
282+
Assert.Contains(logger.Messages, m => m.StartsWith("Created TypeSpec file ", StringComparison.Ordinal));
283+
Assert.DoesNotContain(logger.Messages, m => m.Contains("OpenAPI", StringComparison.Ordinal));
236284
}
237285
finally
238286
{
239287
dir.Delete(recursive: true);
240288
}
241289
}
290+
291+
private sealed class MessageCapturingLogger<T> : ILogger<T>
292+
{
293+
public List<string> Messages { get; } = [];
294+
295+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
296+
297+
public bool IsEnabled(LogLevel logLevel) => true;
298+
299+
public void Log<TState>(
300+
LogLevel logLevel,
301+
EventId eventId,
302+
TState state,
303+
Exception? exception,
304+
Func<TState, Exception?, string> formatter) =>
305+
Messages.Add(formatter(state, exception));
306+
}
242307
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using DevProxy.Abstractions.Proxy;
6+
using DevProxy.Plugins.Reporters;
7+
using DevProxy.Plugins.Reporting;
8+
using Microsoft.Extensions.Logging.Abstractions;
9+
using Xunit;
10+
11+
namespace DevProxy.Integration.Tests;
12+
13+
public sealed class JsonReporterTests
14+
{
15+
private sealed class TestJsonReporter() : JsonReporter(NullLogger<JsonReporter>.Instance, new HashSet<UrlToWatch>())
16+
{
17+
public string? Report(string key, object value) => GetReport(new(key, value));
18+
}
19+
20+
[Fact]
21+
public void FileExtension_IsDeterminedPerReport()
22+
{
23+
var reporter = new TestJsonReporter();
24+
25+
_ = reporter.Report("UrlDiscoveryPlugin", new UrlDiscoveryPluginReport { Data = ["https://api.contoso.com/*"] });
26+
Assert.Equal(".jsonc", reporter.FileExtension);
27+
28+
_ = reporter.Report("OtherPlugin", new { Value = 1 });
29+
Assert.Equal(".json", reporter.FileExtension);
30+
}
31+
}

0 commit comments

Comments
 (0)