MEDIUM: - M1: JsonSerializerOptions.MaxDepth = 32 on the consolidated JsonDefaults.Options (referenced from both ForgeClient and RunResult.AsJson<T>) so the result payload's arbitrary upstream JSON cannot stack-walk the runtime. - M2: JsonDocumentOptions.MaxDepth = 32 in SummarizeBody for parsing error-body summaries — defensive belt alongside the existing 8 MiB body cap. - M3: UploadStreamAsync doc updated to match reality — the input stream IS disposed when the request completes (matches HttpClient / MultipartFormDataContent / StreamContent convention). Old doc was incorrect; chose doc-update over a non-disposing wrapper to stay closest to standard .NET stream semantics. LOW: - L2: RunResult.AsJson<T>() now guards JsonValueKind.Undefined and returns default(T) instead of throwing InvalidOperationException (e.g. when RunResult is constructed without a server payload). - L4: IsNullOrWhiteSpace consistent across RunRequest.Prompt, CreateTokenRequest.Name, RevokeTokenAsync.name, UploadFileAsync.path, UploadStreamAsync.fileName (was IsNullOrEmpty letting space through). Nit polish: - BaseUrl cached in ctor instead of rebuilt per access. - JsonDefaults moved to its own file (Models/JsonDefaults.cs) and is now the single source of truth for serializer options across the client. - examples/Basic/Program.cs comment fixed: '60s' → '120s' to match TimeSpan.FromSeconds(120). README: - HTTPS / WireGuard recommendation in the Notes section — SDK does not enforce HTTPS, callers off-LAN should tunnel. - .NET 8.0.10+ runtime recommendation with cref to CVE-2024-30105 and CVE-2024-43485 (SDK does not exercise the affected code paths; belt-and-suspenders). - UploadStream section reflects the corrected disposal contract. Tests (12 → 19, all passing): - JsonOpts_MaxDepth_RejectsDeeplyNested — 200-deep result rejected via ForgeTransportException wrapping JsonException, no stack overflow. - SummarizeBody_DeeplyNestedHandled — 200-deep error body still produces ForgeAuthException with raw body intact; summary parse fails closed without crashing. - UploadStreamAsync_DisposesCallerStream — DisposeObservingStream helper verifies the contract change. - AsJson_OnUndefinedResult_DefaultReturned — reference + value type. - RunRequest_PromptWithOnlyWhitespace_Rejected. - CreateToken_NameWithOnlyWhitespace_Rejected. - BaseUrl_Cached_ReusesString — Assert.Same identity check. Build: dotnet build -c Release -m:1 clean (0 warnings, 0 errors). Tests: dotnet test -c Release -m:1 → 19 passed, 0 failed. Pack: dotnet pack -c Release -o dist -m:1 clean. Vulns: dotnet list package --vulnerable --include-transitive → 0. Audit: memory/clawdforge-audits/csharp-09aca58.md
547 lines
20 KiB
C#
547 lines
20 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Clawdforge;
|
|
using Clawdforge.Exceptions;
|
|
using Clawdforge.Models;
|
|
using Xunit;
|
|
|
|
namespace Clawdforge.Tests;
|
|
|
|
/// <summary>
|
|
/// Tests use a custom <see cref="HttpMessageHandler"/> mock — zero extra
|
|
/// dependencies, more idiomatic for an SDK than spinning up an ASP.NET
|
|
/// TestHost.
|
|
/// </summary>
|
|
public class ForgeClientTests
|
|
{
|
|
[Fact]
|
|
public async Task Healthz_ReturnsParsedBody()
|
|
{
|
|
var handler = new MockHandler((req, ct) =>
|
|
{
|
|
Assert.Equal(HttpMethod.Get, req.Method);
|
|
Assert.Equal("/healthz", req.RequestUri!.AbsolutePath);
|
|
return JsonResponse(new
|
|
{
|
|
ok = true,
|
|
claude_present = true,
|
|
claude_version = "1.2.3 (Claude Code)",
|
|
});
|
|
});
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var h = await client.HealthzAsync();
|
|
|
|
Assert.True(h.Ok);
|
|
Assert.True(h.ClaudePresent);
|
|
Assert.Equal("1.2.3 (Claude Code)", h.ClaudeVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_SerializesSnakeCase_AndDeserializesResult()
|
|
{
|
|
string? capturedBody = null;
|
|
string? capturedAuth = null;
|
|
|
|
var handler = new MockHandler(async (req, ct) =>
|
|
{
|
|
Assert.Equal(HttpMethod.Post, req.Method);
|
|
Assert.Equal("/run", req.RequestUri!.AbsolutePath);
|
|
capturedAuth = req.Headers.Authorization?.ToString();
|
|
capturedBody = await req.Content!.ReadAsStringAsync(ct);
|
|
|
|
return JsonResponse(new
|
|
{
|
|
ok = true,
|
|
result = new { hello = "world" },
|
|
duration_ms = 4321,
|
|
stop_reason = "end_turn",
|
|
});
|
|
});
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" },
|
|
http);
|
|
|
|
var res = await client.RunAsync(new RunRequest
|
|
{
|
|
Prompt = "Reply with JSON: {\"hello\":\"world\"}",
|
|
Model = "sonnet",
|
|
TimeoutSecs = 60,
|
|
});
|
|
|
|
Assert.True(res.Ok);
|
|
Assert.Equal(4321, res.DurationMs);
|
|
Assert.Equal("end_turn", res.StopReason);
|
|
Assert.Equal(JsonValueKind.Object, res.Result.ValueKind);
|
|
Assert.Equal("world", res.Result.GetProperty("hello").GetString());
|
|
|
|
Assert.Equal("Bearer cf_test", capturedAuth);
|
|
Assert.NotNull(capturedBody);
|
|
// Verify wire shape: snake_case + no nulls.
|
|
using var doc = JsonDocument.Parse(capturedBody!);
|
|
Assert.True(doc.RootElement.TryGetProperty("prompt", out _));
|
|
Assert.True(doc.RootElement.TryGetProperty("model", out _));
|
|
Assert.True(doc.RootElement.TryGetProperty("timeout_secs", out _));
|
|
Assert.False(doc.RootElement.TryGetProperty("system", out _));
|
|
Assert.False(doc.RootElement.TryGetProperty("files", out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_PropagatesStringResult_AsJsonElementString()
|
|
{
|
|
var handler = new MockHandler((req, ct) => JsonResponse(new
|
|
{
|
|
ok = true,
|
|
result = "plain text reply",
|
|
duration_ms = 100,
|
|
stop_reason = "end_turn",
|
|
}));
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var res = await client.RunAsync(new RunRequest { Prompt = "x" });
|
|
Assert.Equal(JsonValueKind.String, res.Result.ValueKind);
|
|
Assert.Equal("plain text reply", res.AsText());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_AsJsonGenericRoundtrips()
|
|
{
|
|
var handler = new MockHandler((req, ct) => JsonResponse(new
|
|
{
|
|
ok = true,
|
|
result = new { qty = 2, unit = "cup", food = "rice" },
|
|
duration_ms = 1,
|
|
stop_reason = "end_turn",
|
|
}));
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var res = await client.RunAsync(new RunRequest { Prompt = "x" });
|
|
var ing = res.AsJson<Ingredient>();
|
|
Assert.NotNull(ing);
|
|
Assert.Equal(2, ing!.Qty);
|
|
Assert.Equal("cup", ing.Unit);
|
|
Assert.Equal("rice", ing.Food);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_401_RaisesForgeAuthException()
|
|
{
|
|
var handler = new MockHandler((req, ct) =>
|
|
JsonResponse(new { detail = "Not authenticated" }, HttpStatusCode.Unauthorized));
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_bad" },
|
|
http);
|
|
|
|
var ex = await Assert.ThrowsAsync<ForgeAuthException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = "x" }));
|
|
Assert.Equal(401, ex.StatusCode);
|
|
Assert.Contains("Not authenticated", ex.Body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_502_RaisesForgeApiException_WithRunFailureBody()
|
|
{
|
|
var handler = new MockHandler((req, ct) =>
|
|
JsonResponse(new
|
|
{
|
|
ok = false,
|
|
error = "timeout after 30s",
|
|
stderr = "...",
|
|
duration_ms = 30001,
|
|
stop_reason = "timeout",
|
|
}, HttpStatusCode.BadGateway));
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var ex = await Assert.ThrowsAsync<ForgeApiException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = "x" }));
|
|
Assert.Equal(502, ex.StatusCode);
|
|
// Auth exception type must NOT match — only ForgeApiException.
|
|
Assert.IsNotType<ForgeAuthException>(ex);
|
|
Assert.Contains("timeout", ex.Body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_TransportFailure_RaisesForgeTransportException()
|
|
{
|
|
var handler = new MockHandler((HttpRequestMessage req, CancellationToken ct) =>
|
|
Task.FromException<HttpResponseMessage>(new HttpRequestException("connection refused")));
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var ex = await Assert.ThrowsAsync<ForgeTransportException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = "x" }));
|
|
Assert.IsType<HttpRequestException>(ex.InnerException);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Run_HonorsCancellation()
|
|
{
|
|
var handler = new MockHandler(async (req, ct) =>
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
|
return JsonResponse(new { ok = true, result = "x", duration_ms = 0, stop_reason = "end_turn" });
|
|
});
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = "x" }, cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UploadFile_StreamsMultipart_AndReturnsToken()
|
|
{
|
|
var seenBoundary = false;
|
|
var seenTtl = false;
|
|
var seenFile = false;
|
|
|
|
var fileName = $"forge-upload-{Guid.NewGuid():N}.txt";
|
|
var tmp = Path.Combine(Path.GetTempPath(), fileName);
|
|
|
|
var handler = new MockHandler(async (req, ct) =>
|
|
{
|
|
Assert.Equal(HttpMethod.Post, req.Method);
|
|
Assert.Equal("/files", req.RequestUri!.AbsolutePath);
|
|
var contentType = req.Content!.Headers.ContentType!;
|
|
Assert.Equal("multipart/form-data", contentType.MediaType);
|
|
Assert.NotNull(contentType.Parameters.SingleOrDefault(p => p.Name == "boundary"));
|
|
seenBoundary = true;
|
|
|
|
var body = await req.Content.ReadAsStringAsync(ct);
|
|
// .NET's MultipartFormDataContent emits unquoted name= / filename= params.
|
|
seenTtl = body.Contains("name=ttl_secs") && body.Contains("3600");
|
|
seenFile = body.Contains("name=file")
|
|
&& body.Contains($"filename={fileName}")
|
|
&& body.Contains("hello forge");
|
|
|
|
return JsonResponse(new { file_token = "ff_abc123", ttl_secs = 3600, size = 11 });
|
|
});
|
|
|
|
await File.WriteAllTextAsync(tmp, "hello forge");
|
|
try
|
|
{
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var ft = await client.UploadFileAsync(tmp, ttlSecs: 3600);
|
|
Assert.Equal("ff_abc123", ft.Token);
|
|
Assert.Equal(3600, ft.TtlSecs);
|
|
Assert.Equal(11, ft.Size);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(tmp);
|
|
}
|
|
|
|
Assert.True(seenBoundary);
|
|
Assert.True(seenTtl, "ttl_secs field not found in multipart body");
|
|
Assert.True(seenFile, "file field/filename not found in multipart body");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdminTokens_FullCrudCycle()
|
|
{
|
|
var calls = new List<(HttpMethod method, string path, string? body)>();
|
|
|
|
var handler = new MockHandler(async (req, ct) =>
|
|
{
|
|
string? body = null;
|
|
if (req.Content is not null)
|
|
{
|
|
body = await req.Content.ReadAsStringAsync(ct);
|
|
}
|
|
calls.Add((req.Method, req.RequestUri!.AbsolutePath, body));
|
|
|
|
return req.Method.Method switch
|
|
{
|
|
"POST" => JsonResponse(new
|
|
{
|
|
name = "petalparse",
|
|
token = "cf_secret_xyz",
|
|
ip_cidrs = new[] { "172.24.0.0/16" },
|
|
}),
|
|
"GET" => JsonResponse(new
|
|
{
|
|
tokens = new object[]
|
|
{
|
|
new { name = "petalparse", ip_cidrs = new[] { "172.24.0.0/16" }, created_at = 1714000000 },
|
|
new { name = "cauldron", ip_cidrs = Array.Empty<string>(), created_at = 1714000100 },
|
|
},
|
|
}),
|
|
"DELETE" => JsonResponse(new { ok = true }),
|
|
_ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed),
|
|
};
|
|
});
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "admin-token" },
|
|
http);
|
|
|
|
var created = await client.CreateTokenAsync(new CreateTokenRequest
|
|
{
|
|
Name = "petalparse",
|
|
IpCidrs = new[] { "172.24.0.0/16" },
|
|
});
|
|
Assert.Equal("petalparse", created.Name);
|
|
Assert.Equal("cf_secret_xyz", created.Token);
|
|
|
|
var list = await client.ListTokensAsync();
|
|
Assert.Equal(2, list.Count);
|
|
Assert.Contains(list, t => t.Name == "cauldron");
|
|
|
|
await client.RevokeTokenAsync("petalparse");
|
|
|
|
Assert.Equal(3, calls.Count);
|
|
Assert.Equal("/admin/tokens", calls[0].path);
|
|
Assert.Equal("/admin/tokens", calls[1].path);
|
|
Assert.Equal("/admin/tokens/petalparse", calls[2].path);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunRequest_WithoutPrompt_ThrowsArgumentException()
|
|
{
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" });
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = "" }));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RunRequest_PromptWithOnlyWhitespace_Rejected()
|
|
{
|
|
// Audit L4: required-string args use IsNullOrWhiteSpace consistently —
|
|
// a prompt of " \t\n " is not a meaningful request.
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" });
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = " \t\n " }));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateToken_NameWithOnlyWhitespace_Rejected()
|
|
{
|
|
// Audit L4: required-string args use IsNullOrWhiteSpace consistently.
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "admin-token" });
|
|
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
client.CreateTokenAsync(new CreateTokenRequest { Name = " " }));
|
|
}
|
|
|
|
[Fact]
|
|
public void BaseUrl_IsTrimmedAtConstruction()
|
|
{
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" });
|
|
|
|
Assert.Equal("http://forge.test", client.BaseUrl);
|
|
}
|
|
|
|
[Fact]
|
|
public void BaseUrl_Cached_ReusesString()
|
|
{
|
|
// Audit nit: BaseUrl getter shouldn't rebuild the string on every
|
|
// access. Identity check confirms a single cached instance.
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test/", Token = "cf_test" });
|
|
|
|
var first = client.BaseUrl;
|
|
var second = client.BaseUrl;
|
|
Assert.Same(first, second);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task JsonOpts_MaxDepth_RejectsDeeplyNested()
|
|
{
|
|
// Audit M1: ForgeClient JsonOpts must cap deserialization depth so
|
|
// the server's `result` field can't stack-walk the runtime via
|
|
// pathological nesting. We synthesize a 200-deep object and verify
|
|
// the SDK surfaces a transport exception (JsonException-wrapped),
|
|
// NOT a stack-overflow / process termination.
|
|
var deepResult = new string('[', 200) + new string(']', 200);
|
|
var body = "{\"ok\":true,\"result\":" + deepResult
|
|
+ ",\"duration_ms\":1,\"stop_reason\":\"end_turn\"}";
|
|
|
|
var handler = new MockHandler((req, ct) => new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
});
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var ex = await Assert.ThrowsAsync<ForgeTransportException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = "x" }));
|
|
Assert.IsType<JsonException>(ex.InnerException);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SummarizeBody_DeeplyNestedHandled()
|
|
{
|
|
// Audit M2: SummarizeBody parses the error body to extract a
|
|
// `detail`/`error`/`message` string. A hostile error body with
|
|
// pathological nesting must not crash the summarize path — the
|
|
// JsonDocumentOptions.MaxDepth=32 cap means JsonDocument.Parse
|
|
// throws JsonException, which SummarizeBody swallows. Verify the
|
|
// 401 still surfaces as ForgeAuthException with body intact.
|
|
var deeplyNestedBody = "{\"detail\":" + new string('[', 200) + new string(']', 200) + "}";
|
|
|
|
var handler = new MockHandler((req, ct) => new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
|
{
|
|
Content = new StringContent(deeplyNestedBody, Encoding.UTF8, "application/json"),
|
|
});
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var ex = await Assert.ThrowsAsync<ForgeAuthException>(() =>
|
|
client.RunAsync(new RunRequest { Prompt = "x" }));
|
|
Assert.Equal(401, ex.StatusCode);
|
|
// Raw body is preserved verbatim; only the summary parse is gated.
|
|
Assert.Contains("detail", ex.Body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UploadStreamAsync_DisposesCallerStream()
|
|
{
|
|
// Audit M3: documented contract is that the caller's stream IS
|
|
// disposed by the SDK once the request completes (matches
|
|
// HttpClient/MultipartFormDataContent/StreamContent convention).
|
|
var handler = new MockHandler((req, ct) =>
|
|
JsonResponse(new { file_token = "ff_x", ttl_secs = 3600, size = 5 }));
|
|
|
|
using var http = new HttpClient(handler);
|
|
using var client = new ForgeClient(
|
|
new ForgeOptions { BaseUrl = "http://forge.test", Token = "cf_test" },
|
|
http);
|
|
|
|
var observed = new DisposeObservingStream(new MemoryStream(Encoding.UTF8.GetBytes("hello")));
|
|
var ft = await client.UploadStreamAsync(observed, "hello.txt", ttlSecs: 3600);
|
|
|
|
Assert.Equal("ff_x", ft.Token);
|
|
Assert.True(observed.WasDisposed,
|
|
"UploadStreamAsync should dispose the caller's stream — see README contract.");
|
|
}
|
|
|
|
[Fact]
|
|
public void AsJson_OnUndefinedResult_DefaultReturned()
|
|
{
|
|
// Audit L2: RunResult.AsJson<T>() previously threw InvalidOperationException
|
|
// when Result.ValueKind == Undefined (default JsonElement). Guarded
|
|
// to return default(T) instead.
|
|
var rr = new RunResult { Ok = true, DurationMs = 0 };
|
|
Assert.Equal(JsonValueKind.Undefined, rr.Result.ValueKind);
|
|
|
|
// Reference type: returns null.
|
|
Assert.Null(rr.AsJson<Ingredient>());
|
|
// Value type: returns default(int) = 0.
|
|
Assert.Equal(0, rr.AsJson<int>());
|
|
}
|
|
|
|
// ---- helpers -----------------------------------------------------------
|
|
|
|
private static HttpResponseMessage JsonResponse(object payload, HttpStatusCode status = HttpStatusCode.OK)
|
|
{
|
|
var json = JsonSerializer.Serialize(payload);
|
|
return new HttpResponseMessage(status)
|
|
{
|
|
Content = new StringContent(json, Encoding.UTF8, "application/json"),
|
|
};
|
|
}
|
|
|
|
private sealed class Ingredient
|
|
{
|
|
public int Qty { get; init; }
|
|
public string Unit { get; init; } = string.Empty;
|
|
public string Food { get; init; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wraps an inner stream and flips a flag the first time
|
|
/// <see cref="Stream.Dispose(bool)"/> runs. Used by the
|
|
/// UploadStreamAsync disposal-contract test.
|
|
/// </summary>
|
|
private sealed class DisposeObservingStream : Stream
|
|
{
|
|
private readonly Stream _inner;
|
|
public bool WasDisposed { get; private set; }
|
|
|
|
public DisposeObservingStream(Stream inner) { _inner = inner; }
|
|
|
|
public override bool CanRead => _inner.CanRead;
|
|
public override bool CanSeek => _inner.CanSeek;
|
|
public override bool CanWrite => _inner.CanWrite;
|
|
public override long Length => _inner.Length;
|
|
public override long Position { get => _inner.Position; set => _inner.Position = value; }
|
|
|
|
public override void Flush() => _inner.Flush();
|
|
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
|
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
|
|
public override void SetLength(long value) => _inner.SetLength(value);
|
|
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
WasDisposed = true;
|
|
if (disposing) _inner.Dispose();
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
private sealed class MockHandler : HttpMessageHandler
|
|
{
|
|
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
|
|
|
|
public MockHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
|
|
{
|
|
_handler = handler;
|
|
}
|
|
|
|
public MockHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> handler)
|
|
: this((req, ct) => Task.FromResult(handler(req, ct))) { }
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return _handler(request, cancellationToken);
|
|
}
|
|
}
|
|
}
|