postit: wire BlogApiClient through YavscApiClient and unify auth

BlogApiClient is a thin DTO↔path mapper on top of YavscApiClient:
- No more HttpClient, no more accessToken constructor argument.
- Single responsibility: turn Yavsc.Blogs endpoint paths into typed
  BlogPost payloads and back, while YavscApiClient owns auth +
  refresh + JSON shape.
- Default path prefix is 'api/blog', overridable for tests.

MainPageViewModel and App.axaml.cs are now free of any direct
OidcClient / IBrowser / BearerToken plumbing. The MainPage receives
a fully-configured BlogApiClient (which holds a YavscApiClient, which
holds a TokenStore) at construction. The duplicate LoginAsync method
on MainPageViewModel is gone; the LoginPage is the single entry point
for the interactive PKCE flow.

PlatformBootstrap.Desktop no longer overrides the redirect URI to
loopback. PostIt runs the postit://callback custom scheme
(SingleInstance hand-off) as the production path on desktop; the
loopback constant stays for tests and for platforms that cannot
register a custom scheme.

Settings.cs: DefaultLoopbackRedirectUri is now documented as a
fallback; DefaultDesktopRedirectUri ('postit://callback') is
introduced as the canonical desktop default.

TokenStore.Load() tolerates an empty file (returns null) so
first-launch races and stubbed test fixtures don't blow up the
constructor.

YavscApiClient:
- HasValidSession is exposed for warm-start UI logic.
- CurrentAccessToken / CurrentIdToken are exposed so the LoginPage
  ViewModel can mirror the result onto its observable properties.
- CallAsync<T> is now virtual (and the class is no longer sealed)
  to allow stubbing in PostItViewModelTests.

Tests (PostIt.Tests):
- YavscApiClientTests covers the silent refresh path (cache the
  token, mark it expired, observe a new Bearer in the API server),
  the 401 → refresh → retry path (forceFirstRequest on the stub),
  HasValidSession after login, and the throw-when-no-token guard.
- OidcStubAuthority now mints a refresh_token in the token response
  so YavscApiClient.RefreshTokenAsync can hit /connect/token in
  tests.
- PostItViewModelTests uses a ThrowingYavscApiClient / StubYavscApiClient
  pair instead of the old HttpClient injection point, matching the
  new constructor shape.

dotnet test: 21/21 green. dotnet build: 0 errors.
This commit is contained in:
Paul Schneider 2026-06-23 21:25:17 +01:00
commit f96d84dc5b
11 changed files with 505 additions and 179 deletions

View file

@ -95,7 +95,6 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
break;
}
}
private Dictionary<string, object> BuildDiscovery() => new()
{
["issuer"] = Issuer,
@ -151,10 +150,13 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
};
var accessToken = SignJwt(claims);
var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
var response = new
{
access_token = accessToken,
id_token = accessToken,
refresh_token = refreshToken,
token_type = "Bearer",
expires_in = 600,
scope = form.TryGetValue("scope", out var s) ? s : "openid",

View file

@ -5,6 +5,7 @@ using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PostIt.Models;
using PostIt.Services;
@ -18,7 +19,12 @@ public class PostItViewModelTests
[Fact]
public void SearchCommand_filters_posts_by_title_article_or_author()
{
var viewModel = new MainPageViewModel();
// MainPageViewModel no longer owns a BlogApiClient instance by
// default; tests construct one with a fake YavscApiClient that
// throws on any call (we never call the API in this test).
var fakeApi = new ThrowingYavscApiClient();
var blog = new BlogApiClient(fakeApi);
var viewModel = new MainPageViewModel(blog);
viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" });
viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" });
@ -40,40 +46,69 @@ public class PostItViewModelTests
[Fact]
public async Task BlogApiClient_GetPostsAsync_returns_posts_from_api()
{
// The new BlogApiClient delegates transport to YavscApiClient.
// We feed it a fake YavscApiClient that returns the expected
// list straight from CallAsync.
var expected = new List<BlogPost>
{
new() { Id = 1, Title = "Hello" },
new() { Id = 2, Title = "World" }
};
var api = new StubYavscApiClient(expected);
var blog = new BlogApiClient(api);
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, JsonSerializer.Serialize(expected));
using var client = new HttpClient(handler)
{
BaseAddress = new System.Uri("http://localhost/")
};
using var apiClient = new BlogApiClient(client);
var posts = await apiClient.GetPostsAsync();
var posts = await blog.GetPostsAsync();
Assert.Equal(2, posts.Count);
Assert.Equal("Hello", posts[0].Title);
}
private sealed class FakeHttpMessageHandler : HttpMessageHandler
/// <summary>Test fake that always throws if the API is invoked.</summary>
private sealed class ThrowingYavscApiClient : YavscApiClient
{
private readonly HttpResponseMessage _response;
public FakeHttpMessageHandler(HttpStatusCode statusCode, string content)
{
_response = new HttpResponseMessage(statusCode)
public ThrowingYavscApiClient() : base(
new Settings
{
Content = new StringContent(content, Encoding.UTF8, "application/json")
};
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{ }
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
=> throw new System.InvalidOperationException("ThrowingYavscApiClient: API not stubbed.");
}
/// <summary>Test fake that hands back a canned list of posts from any CallAsync.</summary>
private sealed class StubYavscApiClient : YavscApiClient
{
private readonly List<BlogPost> _posts;
public StubYavscApiClient(List<BlogPost> posts)
: base(
new Settings
{
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{
_posts = posts;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
return Task.FromResult(_response);
// The canned fake only knows about a list of posts; the
// BlogApiClient test asserts on that list directly.
if (typeof(T) == typeof(List<BlogPost>))
return Task.FromResult((T)(object)_posts);
return Task.FromResult(default(T)!);
}
}
}

View file

@ -0,0 +1,315 @@
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;
/// <summary>
/// End-to-end coverage of <see cref="YavscApiClient"/>: silent
/// refresh on a near-expiry access token, 401-driven refresh + retry,
/// and persistence of the token bundle via <see cref="TokenStore"/>.
/// Uses the project's <see cref="OidcStubAuthority"/> for the IdP and
/// a tiny in-process HTTP listener for the API server side.
/// </summary>
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<List<StubApiServer.Post>>(
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<List<StubApiServer.Post>>(
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<InvalidOperationException>(() =>
client.CallAsync<JsonElement>(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<YavscApiClient> 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;
}
/// <summary>
/// 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.
/// </summary>
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));
}
}
/// <summary>
/// Tiny in-process API server. By default returns 200 with a fixed
/// list of posts. When <paramref name="forceFirstRequest"/> is true,
/// returns 401 on the first request, 200 on subsequent ones — this
/// is what the silent-refresh-on-401 test hooks into.
/// </summary>
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<string> 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<Post>.
// JsonSerializer deserialises arrays to List<T> 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();
}
}