diff --git a/ServiceNow.Api.Test/ReadOnlyOptionTests.cs b/ServiceNow.Api.Test/ReadOnlyOptionTests.cs new file mode 100644 index 0000000..16df4e4 --- /dev/null +++ b/ServiceNow.Api.Test/ReadOnlyOptionTests.cs @@ -0,0 +1,107 @@ +using AwesomeAssertions; +using Newtonsoft.Json.Linq; +using System.Net; +using Xunit; + +namespace ServiceNow.Api.Test; + +/// +/// Credential-free: a refused write throws before any request reaches the handler. +/// +public class ReadOnlyOptionTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private sealed class RecordingHandler : HttpMessageHandler + { + public List Methods { get; } = []; + + protected override Task 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(); + 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(); + 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(); + 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(); + 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); + } +} diff --git a/ServiceNow.Api/Options.cs b/ServiceNow.Api/Options.cs index 072d482..84f6f17 100644 --- a/ServiceNow.Api/Options.cs +++ b/ServiceNow.Api/Options.cs @@ -12,6 +12,11 @@ public class Options /// public ILogger? Logger { get; set; } + /// + /// Whether the client refuses every request other than GET, HEAD and OPTIONS + /// + public bool IsReadOnly { get; set; } + /// /// 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, diff --git a/ServiceNow.Api/ReadOnlyHandler.cs b/ServiceNow.Api/ReadOnlyHandler.cs new file mode 100644 index 0000000..d776070 --- /dev/null +++ b/ServiceNow.Api/ReadOnlyHandler.cs @@ -0,0 +1,21 @@ +namespace ServiceNow.Api; + +/// +/// Refuses every request other than GET, HEAD or OPTIONS while is set. +/// The option is read on every request. +/// +internal sealed class ReadOnlyHandler(Options options, HttpMessageHandler innerHandler) : DelegatingHandler(innerHandler) +{ + protected override Task 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); + } +} diff --git a/ServiceNow.Api/ServiceNowClient.cs b/ServiceNow.Api/ServiceNowClient.cs index 53b8c6f..1875e72 100644 --- a/ServiceNow.Api/ServiceNowClient.cs +++ b/ServiceNow.Api/ServiceNowClient.cs @@ -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 = @@ -80,7 +80,7 @@ internal ServiceNowClient( _logger = _options.Logger ?? new NullLogger(); AccountName = "test"; - _httpClient = new HttpClient(httpMessageHandler) + _httpClient = new HttpClient(new ReadOnlyHandler(_options, httpMessageHandler)) { BaseAddress = new Uri("https://test.service-now.com"), DefaultRequestHeaders =