using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using IdentityModel.OidcClient; using IdentityModel.OidcClient.Browser; using PostIt.Services; using Xunit; namespace PostIt.Tests; /// /// End-to-end coverage of : silent /// refresh on a near-expiry access token, 401-driven refresh + retry, /// and persistence of the token bundle via . /// Uses the project's for the IdP and /// a tiny in-process HTTP listener for the API server side. /// public class YavscApiClientTests { private static int GetFreePort() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); var port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } private static string TokensPath() => Path.Combine( Path.GetTempPath(), $"postit-tests-tokens-{Guid.NewGuid():N}.json"); [Fact] public async Task CallAsync_refreshes_silently_when_access_token_is_about_to_expire() { // The stub OIDC hands out access tokens that expire in 600s. // We construct a YavscApiClient, then forcibly mark the // in-memory access token as expired and re-run a call. The // refresh path must rotate the refresh token transparently // and the API call must succeed with the new token. using var authority = await OidcStubAuthority.StartAsync(); using var apiServer = new StubApiServer(); await apiServer.StartAsync(); var settings = BuildSettings(authority, apiServer.BaseUrl); var tokensPath = TokensPath(); try { var client = await LoginAndPersistAsync( settings, authority, tokensPath); // Mark the cached access token as already expired. ExpireCachedAccessToken(tokensPath); // Reload — YavscApiClient constructor reads the store. var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath)); var posts = await reloaded.CallAsync>( HttpMethod.Get, "posts"); Assert.NotNull(posts); Assert.NotEmpty(posts); // The API server must have seen the new (post-refresh) // bearer token, distinct from the original. var seen = apiServer.SeenBearers.ToList(); Assert.NotEmpty(seen); Assert.Contains(seen, b => !string.IsNullOrEmpty(b)); } finally { if (File.Exists(tokensPath)) File.Delete(tokensPath); } } [Fact] public async Task CallAsync_retries_once_after_401_then_succeeds() { // API server returns 401 on the first request, 200 on the next. // YavscApiClient must refresh, then retry exactly once. using var authority = await OidcStubAuthority.StartAsync(); using var apiServer = new StubApiServer(forceFirstRequest: true); await apiServer.StartAsync(); var settings = BuildSettings(authority, apiServer.BaseUrl); var tokensPath = TokensPath(); try { var client = await LoginAndPersistAsync( settings, authority, tokensPath); var posts = await client.CallAsync>( HttpMethod.Get, "posts"); Assert.NotEmpty(posts); Assert.Equal(2, apiServer.RequestCount); } finally { if (File.Exists(tokensPath)) File.Delete(tokensPath); } } [Fact] public async Task CallAsync_throws_when_no_token_and_no_interactive_login() { var settings = new PostIt.Settings { Authentication = new AuthenticationSettings { Authority = "http://127.0.0.1:1", ClientId = "postit-tests", }, RedirectUri = "http://127.0.0.1:7890/", Scopes = new[] { "openid" }, ApiUrl = "http://127.0.0.1:1/", }; var client = new YavscApiClient(settings, new TokenStore(Path.Combine( Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); await Assert.ThrowsAsync(() => client.CallAsync(HttpMethod.Get, "posts")); } [Fact] public async Task HasValidSession_is_true_after_login() { using var authority = await OidcStubAuthority.StartAsync(); using var apiServer = new StubApiServer(); await apiServer.StartAsync(); var settings = BuildSettings(authority, apiServer.BaseUrl); var tokensPath = TokensPath(); try { var client = await LoginAndPersistAsync( settings, authority, tokensPath); Assert.True(client.HasValidSession, "HasValidSession should be true right after a successful login."); } finally { if (File.Exists(tokensPath)) File.Delete(tokensPath); } } // --- helpers -------------------------------------------------------- private static PostIt.Settings BuildSettings(OidcStubAuthority authority, string apiBaseUrl) => new() { Authentication = new AuthenticationSettings { Authority = authority.Issuer, ClientId = "postit-tests", }, RedirectUri = authority.LoopbackRedirectUri, Scopes = new[] { "openid", "profile", "blog" }, ApiUrl = apiBaseUrl, }; private static async Task LoginAndPersistAsync( PostIt.Settings settings, OidcStubAuthority authority, string tokensPath) { var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); var client = new YavscApiClient(settings, new TokenStore(tokensPath)); // Force the API client to use the test browser by routing the // LoginInteractiveAsync call through a small wrapper. await LoginWithBrowserAsync(client, browser.CreateBrowser()); return client; } /// /// YavscApiClient.LoginInteractiveAsync delegates to /// Platform.CreateBrowser. We can't override that static cleanly /// from xunit.v3, so we rebuild the call by re-routing the /// Platform.CreateBrowser delegate for the duration of the call. /// private static async Task LoginWithBrowserAsync( YavscApiClient client, IBrowser browser) { var original = Platform.CreateBrowser; try { Platform.CreateBrowser = () => browser; await client.LoginInteractiveAsync(); } finally { Platform.CreateBrowser = original; } } private static void ExpireCachedAccessToken(string tokensPath) { var json = File.ReadAllText(tokensPath); var doc = JsonDocument.Parse(json); var record = new RefreshTokenRecord( AccessToken: doc.RootElement.GetProperty("AccessToken").GetString()!, RefreshToken: doc.RootElement.GetProperty("RefreshToken").GetString()!, // Far in the past → refresh path must engage on next call. AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddMinutes(-5), IdToken: doc.RootElement.TryGetProperty("IdToken", out var idt) ? idt.GetString() : null); File.WriteAllText(tokensPath, JsonSerializer.Serialize(record)); } } /// /// Tiny in-process API server. By default returns 200 with a fixed /// list of posts. When is true, /// returns 401 on the first request, 200 on subsequent ones — this /// is what the silent-refresh-on-401 test hooks into. /// internal sealed class StubApiServer : IAsyncDisposable, IDisposable { public record Post(long Id, string Title); private readonly HttpListener _listener; private readonly bool _forceFirstRequest; private int _requestCount; public string BaseUrl { get; private set; } = string.Empty; public List SeenBearers { get; } = new(); public int RequestCount => _requestCount; public StubApiServer(bool forceFirstRequest = false) { _forceFirstRequest = forceFirstRequest; var port = GetFreePort(); _listener = new HttpListener(); _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); } public async Task StartAsync() { _listener.Start(); BaseUrl = _listener.Prefixes.First().TrimEnd('/'); _ = Task.Run(AcceptLoopAsync); await Task.Yield(); } private async Task AcceptLoopAsync() { while (_listener.IsListening) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { return; } Interlocked.Increment(ref _requestCount); // Capture the bearer for assertions. var auth = ctx.Request.Headers["Authorization"]; if (!string.IsNullOrEmpty(auth)) SeenBearers.Add(auth!); if (_forceFirstRequest && _requestCount == 1) { ctx.Response.StatusCode = 401; ctx.Response.Close(); continue; } var payload = new { // Result is an array; the call site expects List. // JsonSerializer deserialises arrays to List fine. Items = new[] { new Post(1, "Hello from stub"), new Post(2, "Second post"), } }; // Wrap in a top-level "Posts" property so the deserialiser // sees { "Posts": [...] }? No — the API client expects a // JSON array directly. We send the array, not the wrapper. var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload.Items)); ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; await ctx.Response.OutputStream.WriteAsync(bytes); ctx.Response.Close(); } } private static int GetFreePort() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); var port = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return port; } public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } public void Dispose() { try { _listener.Stop(); } catch { } _listener.Close(); } }