Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs
mika kuns cfe23cdd23 fix(online-inbox): invalidate cached access token when the signed-in user changes
ZitadelAuthProvider cached the access token in memory and only re-read the
refresh token when the cache expired. Re-signing as a different user saved a
new refresh token but the worker kept serving the previous user's cached
access token until it expired — so sync (and ownerId stamping) continued under
the old identity.

Track the refresh token that minted the cached token and invalidate the cache
when the stored refresh token changes (user switch or sign-out). Switching
users now takes effect on the next sync without a worker restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:38:31 +02:00

273 lines
10 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using ClaudeDo.Worker.Online;
using Microsoft.Extensions.Logging.Abstractions;
namespace ClaudeDo.Worker.Tests.Online;
/// <summary>
/// Tests for <see cref="ZitadelAuthProvider"/> using a stub <see cref="HttpMessageHandler"/>.
/// Token-store tests use a real temp-dir <see cref="OnlineTokenStore"/> (DPAPI) and are
/// Windows-only, consistent with <see cref="OnlineTokenStoreTests"/>.
/// </summary>
public sealed class ZitadelAuthProviderTests : IDisposable
{
// ── Stubs ───────────────────────────────────────────────────────────────
private sealed class StubHandler : HttpMessageHandler
{
public record Expectation(string UrlContains, HttpStatusCode Status, string Body);
private readonly Queue<Expectation> _queue = new();
public List<HttpRequestMessage> Requests { get; } = new();
public void Enqueue(string urlContains, HttpStatusCode status, string body)
=> _queue.Enqueue(new Expectation(urlContains, status, body));
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add(request);
if (_queue.Count == 0)
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("Unexpected request", Encoding.UTF8, "text/plain"),
});
var exp = _queue.Dequeue();
return Task.FromResult(new HttpResponseMessage(exp.Status)
{
Content = new StringContent(exp.Body, Encoding.UTF8, "application/json"),
});
}
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly StubHandler _handler;
public StubHttpClientFactory(StubHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler) { BaseAddress = null };
}
// ── Helpers ─────────────────────────────────────────────────────────────
private readonly string _tokenPath = Path.Combine(
Path.GetTempPath(), $"zitadel_token_{Guid.NewGuid():N}.bin");
public void Dispose()
{
try { File.Delete(_tokenPath); } catch { }
}
private static string DiscoveryJson(string tokenEndpoint) =>
JsonSerializer.Serialize(new { token_endpoint = tokenEndpoint });
private static string TokenJson(string accessToken, int expiresIn = 3600, string? refreshToken = null)
{
var obj = new Dictionary<string, object>
{
["access_token"] = accessToken,
["expires_in"] = expiresIn,
};
if (refreshToken is not null) obj["refresh_token"] = refreshToken;
return JsonSerializer.Serialize(obj);
}
private static OnlineInboxConfig MakeConfig() => new()
{
Enabled = true,
ApiBaseUrl = "https://api.example.com",
Zitadel = new ZitadelClientConfig
{
Authority = "https://auth.example.com",
ClientId = "client-abc",
Scopes = "openid offline_access",
},
};
private (ZitadelAuthProvider Provider, StubHandler Handler, OnlineTokenStore Store) Build()
{
var handler = new StubHandler();
var factory = new StubHttpClientFactory(handler);
var store = new OnlineTokenStore(_tokenPath);
var config = MakeConfig();
var provider = new ZitadelAuthProvider(
factory, store, config, NullLogger<ZitadelAuthProvider>.Instance);
return (provider, handler, store);
}
// ── Tests ────────────────────────────────────────────────────────────────
[Fact]
public async Task NoStoredToken_ReturnsNull_WithNoHttpCall()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, _) = Build();
var result = await provider.GetAccessTokenAsync();
Assert.Null(result);
Assert.Empty(handler.Requests);
}
[Fact]
public async Task SuccessfulRefresh_ReturnsAccessToken()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("initial-refresh-token");
handler.Enqueue(".well-known/openid-configuration", HttpStatusCode.OK,
DiscoveryJson("https://auth.example.com/oauth/token"));
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("access-token-xyz", expiresIn: 3600));
var result = await provider.GetAccessTokenAsync();
Assert.Equal("access-token-xyz", result);
}
[Fact]
public async Task SuccessfulRefresh_CachesToken_SecondCallMakesNoRequest()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("initial-refresh-token");
handler.Enqueue(".well-known", HttpStatusCode.OK,
DiscoveryJson("https://auth.example.com/oauth/token"));
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("access-token-xyz", expiresIn: 3600));
var first = await provider.GetAccessTokenAsync();
var second = await provider.GetAccessTokenAsync(); // should use cache
Assert.Equal("access-token-xyz", first);
Assert.Equal("access-token-xyz", second);
// Only two HTTP requests: discovery + token; no third request.
Assert.Equal(2, handler.Requests.Count);
}
[Fact]
public async Task ChangedRefreshToken_InvalidatesCache_AndRefreshesForNewUser()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("admin-refresh");
// First user (admin): discovery + token.
handler.Enqueue(".well-known", HttpStatusCode.OK,
DiscoveryJson("https://auth.example.com/oauth/token"));
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("admin-access", expiresIn: 3600));
var adminToken = await provider.GetAccessTokenAsync();
Assert.Equal("admin-access", adminToken);
// Re-sign-in as a different user writes a new refresh token to the store.
store.Save("normal-refresh");
// Even though the cached admin token is still within its expiry window, the changed
// refresh token must force a new exchange (no second discovery — it's cached).
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("normal-access", expiresIn: 3600));
var normalToken = await provider.GetAccessTokenAsync();
Assert.Equal("normal-access", normalToken);
Assert.Equal(3, handler.Requests.Count); // discovery + admin token + normal token
}
[Fact]
public async Task RotatedRefreshToken_IsPersisted()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("original-refresh");
handler.Enqueue(".well-known", HttpStatusCode.OK,
DiscoveryJson("https://auth.example.com/oauth/token"));
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("access-token", expiresIn: 3600, refreshToken: "rotated-refresh"));
await provider.GetAccessTokenAsync();
var persisted = store.Read();
Assert.Equal("rotated-refresh", persisted);
}
[Fact]
public async Task InvalidGrant_ReturnsNull_DoesNotCrash()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("bad-refresh-token");
handler.Enqueue(".well-known", HttpStatusCode.OK,
DiscoveryJson("https://auth.example.com/oauth/token"));
handler.Enqueue("oauth/token", HttpStatusCode.BadRequest,
JsonSerializer.Serialize(new { error = "invalid_grant", error_description = "token expired" }));
var result = await provider.GetAccessTokenAsync();
Assert.Null(result);
}
[Fact]
public async Task TokenEndpoint_ReadFromDiscovery()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("refresh-token");
const string expectedEndpoint = "https://auth.example.com/custom/token";
handler.Enqueue(".well-known", HttpStatusCode.OK,
DiscoveryJson(expectedEndpoint));
handler.Enqueue("custom/token", HttpStatusCode.OK,
TokenJson("access-token"));
await provider.GetAccessTokenAsync();
// Second request (token POST) should go to the discovered endpoint.
Assert.Equal(2, handler.Requests.Count);
Assert.Contains("custom/token", handler.Requests[1].RequestUri!.AbsoluteUri);
}
[Fact]
public async Task DiscoveryCached_SecondRefreshSkipsDiscovery()
{
if (!OperatingSystem.IsWindows()) return;
var (provider, handler, store) = Build();
store.Save("refresh-token");
// First call: discovery + token (access token expires immediately — use short expiry).
handler.Enqueue(".well-known", HttpStatusCode.OK,
DiscoveryJson("https://auth.example.com/oauth/token"));
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("access-1", expiresIn: -100)); // already expired
await provider.GetAccessTokenAsync();
// Second call: cache is stale, but discovery must NOT be re-fetched.
handler.Enqueue("oauth/token", HttpStatusCode.OK,
TokenJson("access-2", expiresIn: 3600));
var second = await provider.GetAccessTokenAsync();
Assert.Equal("access-2", second);
// Exactly 3 requests: discovery, token(1), token(2) — no second discovery.
Assert.Equal(3, handler.Requests.Count);
Assert.DoesNotContain(handler.Requests.Skip(1), r =>
r.RequestUri!.AbsoluteUri.Contains(".well-known"));
}
}