The Online API now requires the "user" project role (claim urn:zitadel:iam:org:project:roles) instead of an ALLOWED_USER_IDS allowlist. - IOnlineAuthProvider: add GetAccessTokenAsync(forceRefresh) overload - ZitadelAuthProvider: forceRefresh drops the cached token and re-runs the refresh-token grant to mint a fresh, role-bearing token - OnlineInboxApiClient: on 401, force-refresh and retry once; if still 401, throw a clear "missing 'user' role" error - OnlineSyncService: surface the 401 at Error level (no longer silent) - UI: ZitadelTokenInspector decodes the access token after login and warns early when the "user" role is absent (fail-open); shown in settings - docs: online-inbox-api-contract reflects role-based access (no allowlist) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
7.6 KiB
C#
232 lines
7.6 KiB
C#
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ClaudeDo.Worker.Online;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Online;
|
|
|
|
/// <summary>
|
|
/// Tests for <see cref="OnlineInboxApiClient"/> using a stubbed <see cref="HttpMessageHandler"/>.
|
|
/// </summary>
|
|
public sealed class OnlineInboxApiClientTests
|
|
{
|
|
private sealed class StubHandler : HttpMessageHandler
|
|
{
|
|
public List<HttpRequestMessage> Requests { get; } = new();
|
|
public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK;
|
|
public string ResponseBody { get; set; } = "[]";
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
Requests.Add(request);
|
|
var resp = new HttpResponseMessage(ResponseStatus)
|
|
{
|
|
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
|
|
};
|
|
return Task.FromResult(resp);
|
|
}
|
|
}
|
|
|
|
private static (OnlineInboxApiClient Client, StubHandler Handler) Build(string? token = "test-token")
|
|
{
|
|
var handler = new StubHandler();
|
|
// Base address carries a path segment (/api) — requests must nest under it,
|
|
// so the client uses relative paths without a leading slash.
|
|
var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/api/") };
|
|
var auth = new StaticTokenAuthProvider(token);
|
|
return (new OnlineInboxApiClient(http, auth), handler);
|
|
}
|
|
|
|
// ---- PutListsAsync ----
|
|
|
|
[Fact]
|
|
public async Task PutListsAsync_UsesCorrectVerbAndPath()
|
|
{
|
|
var (client, handler) = Build();
|
|
await client.PutListsAsync([new RemoteList("id1", "List 1")]);
|
|
|
|
Assert.Single(handler.Requests);
|
|
Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
|
|
Assert.Equal("/api/lists", handler.Requests[0].RequestUri!.AbsolutePath);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PutListsAsync_AttachesBearerToken()
|
|
{
|
|
var (client, handler) = Build("my-bearer");
|
|
await client.PutListsAsync([]);
|
|
|
|
Assert.Equal("Bearer", handler.Requests[0].Headers.Authorization!.Scheme);
|
|
Assert.Equal("my-bearer", handler.Requests[0].Headers.Authorization!.Parameter);
|
|
}
|
|
|
|
// ---- GetUnimportedTasksAsync ----
|
|
|
|
[Fact]
|
|
public async Task GetUnimportedTasksAsync_UsesGetWithQueryParam()
|
|
{
|
|
var (client, handler) = Build();
|
|
handler.ResponseBody = "[]";
|
|
await client.GetUnimportedTasksAsync();
|
|
|
|
Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
|
|
Assert.Contains("imported=false", handler.Requests[0].RequestUri!.Query);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetUnimportedTasksAsync_DeserializesResponse()
|
|
{
|
|
var (client, handler) = Build();
|
|
var tasks = new[]
|
|
{
|
|
new { id = "t1", listId = "l1", title = "Title", description = (string?)null, createdAt = DateTimeOffset.UtcNow },
|
|
};
|
|
handler.ResponseBody = JsonSerializer.Serialize(tasks);
|
|
|
|
var result = await client.GetUnimportedTasksAsync();
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal("t1", result[0].Id);
|
|
Assert.Equal("l1", result[0].ListId);
|
|
Assert.Equal("Title", result[0].Title);
|
|
}
|
|
|
|
// ---- MarkImportedAsync ----
|
|
|
|
[Fact]
|
|
public async Task MarkImportedAsync_UsesPostAndCorrectPath()
|
|
{
|
|
var (client, handler) = Build();
|
|
await client.MarkImportedAsync("task-id-123");
|
|
|
|
Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
|
|
Assert.Equal("/api/tasks/task-id-123/imported", handler.Requests[0].RequestUri!.AbsolutePath);
|
|
}
|
|
|
|
// ---- PutMirrorAsync ----
|
|
|
|
[Fact]
|
|
public async Task PutMirrorAsync_UsesPutAndCorrectPath()
|
|
{
|
|
var (client, handler) = Build();
|
|
await client.PutMirrorAsync([new MirrorTask("id1", "l1", "T", null)]);
|
|
|
|
Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
|
|
Assert.Equal("/api/tasks/mirror", handler.Requests[0].RequestUri!.AbsolutePath);
|
|
}
|
|
|
|
// ---- 401 handling ----
|
|
|
|
[Fact]
|
|
public async Task NonSuccessResponse_Throws_OnlineInboxException()
|
|
{
|
|
var (client, handler) = Build();
|
|
handler.ResponseStatus = HttpStatusCode.Unauthorized;
|
|
handler.ResponseBody = "Unauthorized";
|
|
|
|
var ex = await Assert.ThrowsAsync<OnlineInboxException>(
|
|
() => client.PutListsAsync([]));
|
|
|
|
Assert.Equal(401, ex.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unauthorized_RetriesOnceWithForcedRefresh_ThenThrowsMissingRole()
|
|
{
|
|
var (client, handler) = Build();
|
|
handler.ResponseStatus = HttpStatusCode.Unauthorized;
|
|
handler.ResponseBody = "Unauthorized";
|
|
|
|
var ex = await Assert.ThrowsAsync<OnlineInboxException>(
|
|
() => client.PutListsAsync([]));
|
|
|
|
Assert.Equal(401, ex.StatusCode);
|
|
Assert.Equal(OnlineInboxApiClient.MissingRoleMessage, ex.Message);
|
|
// Original attempt + one forced-refresh retry.
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unauthorized_ThenSuccessOnRetry_Succeeds()
|
|
{
|
|
var handler = new SequenceHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK);
|
|
var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/api/") };
|
|
var client = new OnlineInboxApiClient(http, new StaticTokenAuthProvider("test-token"));
|
|
|
|
await client.PutListsAsync([]);
|
|
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
}
|
|
|
|
private sealed class SequenceHandler : HttpMessageHandler
|
|
{
|
|
private readonly Queue<HttpStatusCode> _statuses;
|
|
public List<HttpRequestMessage> Requests { get; } = new();
|
|
|
|
public SequenceHandler(params HttpStatusCode[] statuses) => _statuses = new(statuses);
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
|
{
|
|
Requests.Add(request);
|
|
var status = _statuses.Count > 0 ? _statuses.Dequeue() : HttpStatusCode.OK;
|
|
return Task.FromResult(new HttpResponseMessage(status)
|
|
{
|
|
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
|
});
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
|
|
{
|
|
var (client, handler) = Build();
|
|
handler.ResponseStatus = HttpStatusCode.InternalServerError;
|
|
handler.ResponseBody = "error";
|
|
|
|
var ex = await Assert.ThrowsAsync<OnlineInboxException>(
|
|
() => client.GetUnimportedTasksAsync());
|
|
|
|
Assert.Equal(500, ex.StatusCode);
|
|
}
|
|
|
|
// ---- No token ----
|
|
|
|
[Fact]
|
|
public async Task NoToken_SendsRequestWithoutAuthHeader()
|
|
{
|
|
var (client, handler) = Build(token: null);
|
|
await client.PutListsAsync([]);
|
|
|
|
Assert.Null(handler.Requests[0].Headers.Authorization);
|
|
}
|
|
|
|
// ---- URL validation ----
|
|
|
|
[Fact]
|
|
public void ValidateBaseUrl_AcceptsHttps()
|
|
{
|
|
OnlineInboxApiClient.ValidateBaseUrl("https://example.com");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateBaseUrl_AcceptsLoopback()
|
|
{
|
|
OnlineInboxApiClient.ValidateBaseUrl("http://127.0.0.1:5000");
|
|
OnlineInboxApiClient.ValidateBaseUrl("http://localhost:5000");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateBaseUrl_Rejects_HttpNonLoopback()
|
|
{
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => OnlineInboxApiClient.ValidateBaseUrl("http://example.com"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateBaseUrl_Rejects_Empty()
|
|
{
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => OnlineInboxApiClient.ValidateBaseUrl(""));
|
|
}
|
|
}
|