@@ -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 {
0 commit comments