using System.Net; using System.Text; using System.Text.Json; using ClaudeDo.Worker.Online; using Microsoft.Extensions.Logging.Abstractions; namespace ClaudeDo.Worker.Tests.Online; /// /// Tests for using a stub . /// Token-store tests use a real temp-dir (DPAPI) and are /// Windows-only, consistent with . /// public sealed class ZitadelAuthProviderTests : IDisposable { // ── Stubs ─────────────────────────────────────────────────────────────── private sealed class StubHandler : HttpMessageHandler { public record Expectation(string UrlContains, HttpStatusCode Status, string Body); private readonly Queue _queue = new(); public List Requests { get; } = new(); public void Enqueue(string urlContains, HttpStatusCode status, string body) => _queue.Enqueue(new Expectation(urlContains, status, body)); protected override Task 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 { ["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.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 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")); } }