From c30b241597815810121ab4602aa1ff08c1e3f0eb Mon Sep 17 00:00:00 2001 From: David Bond Date: Mon, 2 Nov 2026 21:48:55 +0000 Subject: [PATCH] feat: add IsReadOnly to OpenProjectClientOptions --- .../Tests/ReadOnlyOptionTests.cs | 58 +++++++++++++++++++ .../AuthenticatedHttpClientHandler.cs | 8 +++ OpenProject.Api/OpenProjectClientOptions.cs | 5 ++ 3 files changed, 71 insertions(+) create mode 100644 OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs diff --git a/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs b/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs new file mode 100644 index 0000000..e4f86ad --- /dev/null +++ b/OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs @@ -0,0 +1,58 @@ +using OpenProject.Api.Data.Models.Create; + +namespace OpenProject.Api.Test.Tests; + +/// +/// needs no configuration, network or server. +/// +public class ReadOnlyOptionTests +{ + private static bool IsRefused(Exception e) + => e is InvalidOperationException || e.InnerException is InvalidOperationException; + + private static OpenProjectClient ClientFor(bool isReadOnly) + => new(new OpenProjectClientOptions + { + Uri = new Uri("http://127.0.0.1:1"), + ApiKey = "not-a-real-key", + IsReadOnly = isReadOnly + }); + + [Fact] + public void IsReadOnly_DefaultsToFalse() + => new OpenProjectClientOptions { Uri = new Uri("http://127.0.0.1:1"), ApiKey = "k" } + .IsReadOnly.Should().BeFalse(); + + [Fact] + public async Task ReadOnly_Create_ThrowsInvalidOperationBeforeSending() + { + using var client = ClientFor(isReadOnly: true); + + var act = () => client.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None); + + var thrown = await act.Should().ThrowAsync(); + IsRefused(thrown.Which).Should().BeTrue(); + } + + [Fact] + public async Task NotReadOnly_Create_IsAttemptedAndFailsOnConnectionNotOnPolicy() + { + using var client = ClientFor(isReadOnly: false); + + var act = () => client.Projects.CreateAsync(new ProjectCreate { Name = "p" }, CancellationToken.None); + + var thrown = await act.Should().ThrowAsync(); + IsRefused(thrown.Which).Should().BeFalse(); + } + + [Fact] + public async Task ReadOnly_Get_IsNotRefused() + { + using var client = ClientFor(isReadOnly: true); + + var act = () => client.Projects.GetAllAsync(CancellationToken.None); + + var thrown = await act.Should().ThrowAsync(); + IsRefused(thrown.Which).Should().BeFalse(); + } +} diff --git a/OpenProject.Api/AuthenticatedHttpClientHandler.cs b/OpenProject.Api/AuthenticatedHttpClientHandler.cs index 51d6187..a87e5e7 100644 --- a/OpenProject.Api/AuthenticatedHttpClientHandler.cs +++ b/OpenProject.Api/AuthenticatedHttpClientHandler.cs @@ -34,6 +34,14 @@ protected override async 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; {request.Method} {request.RequestUri?.AbsolutePath} would change the server."); + } + // Set basic auth header request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"apikey:{_options.ApiKey}"))); diff --git a/OpenProject.Api/OpenProjectClientOptions.cs b/OpenProject.Api/OpenProjectClientOptions.cs index f72a8cb..68d6f9e 100644 --- a/OpenProject.Api/OpenProjectClientOptions.cs +++ b/OpenProject.Api/OpenProjectClientOptions.cs @@ -25,6 +25,11 @@ public class OpenProjectClientOptions /// public bool LogExceptionContent { get; init; } + /// + /// When true, the client refuses every request other than GET, HEAD and OPTIONS before sending it. + /// + public bool IsReadOnly { get; set; } + /// /// Validates that the required options are set. ///