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
107 changes: 107 additions & 0 deletions ServiceNow.Api.Test/ReadOnlyOptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using AwesomeAssertions;
using Newtonsoft.Json.Linq;
using System.Net;
using Xunit;

namespace ServiceNow.Api.Test;

/// <summary>
/// Credential-free: a refused write throws before any request reaches the handler.
/// </summary>
public class ReadOnlyOptionTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;

private sealed class RecordingHandler : HttpMessageHandler
{
public List<HttpMethod> Methods { get; } = [];

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Methods.Add(request.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"result\":{\"sys_id\":\"abc\"}}")
});
}
}

private static ServiceNowClient CreateClient(RecordingHandler handler, bool isReadOnly)
=> new(handler, new Options { IsReadOnly = isReadOnly });

[Fact]
public void IsReadOnly_DefaultsToFalse()
=> new Options().IsReadOnly.Should().BeFalse();

[Fact]
public async Task Create_IsRefused_WhenIsReadOnly()
{
using var handler = new RecordingHandler();
using var client = CreateClient(handler, isReadOnly: true);

var act = () => client.CreateAsync("incident", new JObject { ["short_description"] = "x" }, Ct);

await act.Should().ThrowAsync<InvalidOperationException>();
handler.Methods.Should().BeEmpty();
}

[Fact]
public async Task Update_IsRefused_WhenIsReadOnly()
{
using var handler = new RecordingHandler();
using var client = CreateClient(handler, isReadOnly: true);

var act = () => client.UpdateAsync("incident", new JObject { ["sys_id"] = "abc" }, Ct);

await act.Should().ThrowAsync<InvalidOperationException>();
handler.Methods.Should().BeEmpty();
}

[Fact]
public async Task Patch_IsRefused_WhenIsReadOnly()
{
using var handler = new RecordingHandler();
using var client = CreateClient(handler, isReadOnly: true);

var act = () => client.PatchAsync("incident", new JObject { ["sys_id"] = "abc" }, Ct);

await act.Should().ThrowAsync<InvalidOperationException>();
handler.Methods.Should().BeEmpty();
}

[Fact]
public async Task Delete_IsRefused_WhenIsReadOnly()
{
using var handler = new RecordingHandler();
using var client = CreateClient(handler, isReadOnly: true);

var act = () => client.DeleteAsync("incident", "abc", Ct);

await act.Should().ThrowAsync<InvalidOperationException>();
handler.Methods.Should().BeEmpty();
}

[Fact]
public async Task Delete_IsAllowed_WhenNotReadOnly()
{
using var handler = new RecordingHandler();
using var client = CreateClient(handler, isReadOnly: false);

await client.DeleteAsync("incident", "abc", Ct);

handler.Methods.Should().ContainSingle().Which.Should().Be(HttpMethod.Delete);
}

[Fact]
public async Task Get_IsAllowed_WhenIsReadOnly()
{
using var handler = new RecordingHandler();
using var client = CreateClient(handler, isReadOnly: true);

var result = await client.GetByIdAsync("incident", "abc", Ct);

result.Should().NotBeNull();
handler.Methods.Should().ContainSingle().Which.Should().Be(HttpMethod.Get);
}
}
5 changes: 5 additions & 0 deletions ServiceNow.Api/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public class Options
/// </summary>
public ILogger? Logger { get; set; }

/// <summary>
/// Whether the client refuses every request other than GET, HEAD and OPTIONS
/// </summary>
public bool IsReadOnly { get; set; }

/// <summary>
/// The field that is used in the "GetAllByQueryInternalAsync" method in ServiceNowClient.
/// This can be set because it CAN happen that the table/view does NOT have this field,
Expand Down
21 changes: 21 additions & 0 deletions ServiceNow.Api/ReadOnlyHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace ServiceNow.Api;

/// <summary>
/// Refuses every request other than GET, HEAD or OPTIONS while <see cref="Options.IsReadOnly"/> is set.
/// The option is read on every request.
/// </summary>
internal sealed class ReadOnlyHandler(Options options, HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler)
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (options.IsReadOnly
&& request.Method != HttpMethod.Get
&& request.Method != HttpMethod.Head
&& request.Method != HttpMethod.Options)
{
throw new InvalidOperationException($"The client is read-only (IsReadOnly is true): {request.Method} {request.RequestUri?.AbsolutePath} is not allowed.");
}

return base.SendAsync(request, cancellationToken);
}
}
4 changes: 2 additions & 2 deletions ServiceNow.Api/ServiceNowClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public ServiceNowClient(
? $"https://{account}.servicenowservices.com"
: $"https://{account}.service-now.com";
var httpClientHandler = new HttpClientHandler();
_httpClient = new HttpClient(httpClientHandler)
_httpClient = new HttpClient(new ReadOnlyHandler(_options, httpClientHandler))
{
BaseAddress = new Uri(baseAddress),
DefaultRequestHeaders =
Expand Down Expand Up @@ -80,7 +80,7 @@ internal ServiceNowClient(
_logger = _options.Logger ?? new NullLogger<ServiceNowClient>();
AccountName = "test";

_httpClient = new HttpClient(httpMessageHandler)
_httpClient = new HttpClient(new ReadOnlyHandler(_options, httpMessageHandler))
{
BaseAddress = new Uri("https://test.service-now.com"),
DefaultRequestHeaders =
Expand Down
Loading