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
58 changes: 58 additions & 0 deletions OpenProject.Api.Test/Tests/ReadOnlyOptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using OpenProject.Api.Data.Models.Create;

namespace OpenProject.Api.Test.Tests;

/// <summary>
/// <see cref="OpenProjectClientOptions.IsReadOnly"/> needs no configuration, network or server.
/// </summary>
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<Exception>();
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<Exception>();
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<Exception>();
IsRefused(thrown.Which).Should().BeFalse();
}
}
8 changes: 8 additions & 0 deletions OpenProject.Api/AuthenticatedHttpClientHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ protected override async 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; {request.Method} {request.RequestUri?.AbsolutePath} would change the server.");
}
Comment on lines +37 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Suggestion: Consider extracting the read-only method check into a focused helper and separating other cohesive concerns in SendAsync to reduce complexity and improve testability. Add focused coverage for the extracted behavior.

See Issue in Codacy

TIP React 👍 if this review is right, or reply with what's wrong and how it could be better to help improve the reviewer.


// Set basic auth header
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"apikey:{_options.ApiKey}")));

Expand Down
5 changes: 5 additions & 0 deletions OpenProject.Api/OpenProjectClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public class OpenProjectClientOptions
/// </summary>
public bool LogExceptionContent { get; init; }

/// <summary>
/// When true, the client refuses every request other than GET, HEAD and OPTIONS before sending it.
/// </summary>
public bool IsReadOnly { get; set; }

/// <summary>
/// Validates that the required options are set.
/// </summary>
Expand Down
Loading