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.
2026-06-23 21:25:17 +01:00
|
|
|
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;
|
feat(api-client): add Yavsc.Api.Client with Blog + Circle + BlogAcl clients
Creates the high-level HTTP client library the PostIt UI will
consume to manage blog posts, circles, and per-post ACLs.
Clients in this commit:
- BlogApiClient (moved from PostIt/Services; same public surface,
now depends on IYavscApiClient instead of the concrete class).
- CircleApiClient (new): GET/POST/PUT/DELETE /api/circle. Takes
the blogs base URL explicitly in its constructor so it doesn't
need to know about PostIt's Settings type.
- BlogAclApiClient (new): GET/POST/PUT/DELETE /api/blogacl.
Same conventions as CircleApiClient.
DTOs (Yavsc.Api.Client.Dtos):
- CircleDto: id, name, ownerId, public. Stops short of the
navigation properties on the server-side Circle (Owner,
Members), which depend on ApplicationUser and other server
types we don't want to drag into the client.
- CircleAuthorizationDto: circleId, blogPostId, comment. Same
reason: the server entity has Target and Allowed navigation
properties the client never needs.
The clients now require the caller to pass the blogs base URL
explicitly in the constructor (previously the BlogApiClient
sniffed it off YavscApiClient.Settings.BlogsApiUrl, but that
field is PostIt-specific). The one production call site
(App.axaml.cs) and four test call sites are updated to pass
the URL.
Build + 51/51 tests green. The IYavscApiClient abstraction was
landed in the previous commit so this one could be a pure
addition + relocation.
2026-08-17 23:50:35 +01:00
|
|
|
using Yavsc.Blogspot;
|
|
|
|
|
using Yavsc.Api.Client;
|
|
|
|
|
using PostIt.Services;
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using IdentityModel.OidcClient;
|
|
|
|
|
using IdentityModel.OidcClient.Browser;
|
|
|
|
|
using PostIt.Services;
|
2026-07-08 19:35:43 +01:00
|
|
|
using PostIt.ViewModels;
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
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"/>.
|
2026-07-05 23:56:10 +01:00
|
|
|
/// Uses the project's <see cref="OIDCStubAuthority"/> for the IdP and
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
/// 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.
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
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));
|
2026-07-08 19:35:43 +01:00
|
|
|
// Same BaseAddress dance as LoginAndPersistAsync: a fresh
|
|
|
|
|
// YavscApiClient starts with no BaseAddress, and the test
|
|
|
|
|
// calls CallAsync("posts", ...) directly (bypassing
|
|
|
|
|
// BlogApiClient, which is the only thing that would set
|
|
|
|
|
// it in production). Mirror prod here.
|
|
|
|
|
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
|
|
|
|
|
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
|
2026-07-05 23:56:10 +01:00
|
|
|
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
|
|
|
|
|
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.
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
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>>(
|
2026-07-05 23:56:10 +01:00
|
|
|
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
{
|
2026-07-08 19:35:43 +01:00
|
|
|
var settings = new Settings
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
{
|
|
|
|
|
Authentication = new AuthenticationSettings
|
|
|
|
|
{
|
2026-06-25 00:08:25 +01:00
|
|
|
Authority = "https://127.0.0.1:5001",
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
ClientId = "postit-tests",
|
2026-07-08 19:35:43 +01:00
|
|
|
RedirectUri = "postit://callback",
|
|
|
|
|
Scopes = new[] { "openid" },
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
},
|
2026-07-08 19:35:43 +01:00
|
|
|
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
};
|
|
|
|
|
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
|
|
|
|
|
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
|
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
|
|
|
() =>
|
|
|
|
|
client.CallAsync<JsonElement>(HttpMethod.Get, "posts", TestContext.Current.CancellationToken));
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task HasValidSession_is_true_after_login()
|
|
|
|
|
{
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
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 --------------------------------------------------------
|
|
|
|
|
|
2026-07-08 19:35:43 +01:00
|
|
|
private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
{
|
|
|
|
|
Authentication = new AuthenticationSettings
|
|
|
|
|
{
|
|
|
|
|
Authority = authority.Issuer,
|
|
|
|
|
ClientId = "postit-tests",
|
2026-07-08 19:35:43 +01:00
|
|
|
RedirectUri = authority.LoopbackRedirectUri,
|
|
|
|
|
Scopes = new[] { "openid", "profile", "blog" }
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
},
|
2026-07-08 19:35:43 +01:00
|
|
|
BusinessApiUrl = apiBaseUrl
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
2026-07-08 19:35:43 +01:00
|
|
|
Settings settings, OIDCStubAuthority authority, string tokensPath)
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
{
|
|
|
|
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
|
|
|
|
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
|
|
|
|
|
|
2026-07-08 19:35:43 +01:00
|
|
|
// The two integration tests that call CallAsync("posts", ...)
|
|
|
|
|
// directly (bypassing BlogApiClient) rely on the same
|
|
|
|
|
// BaseAddress the production chain sets in BlogApiClient's
|
|
|
|
|
// ctor. Mirror that here so "posts" resolves to the stub.
|
|
|
|
|
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
|
|
|
|
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
// 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
|
2026-07-05 23:56:10 +01:00
|
|
|
/// from XUnit.v3, so we rebuild the call by re-routing the
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
/// 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));
|
|
|
|
|
}
|
2026-06-27 12:47:52 +01:00
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
// --- OIDCLoginPhase progress tests ---------------------------------
|
2026-06-27 12:47:52 +01:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Collecting Progress<T> is documented to capture reports
|
|
|
|
|
/// synchronously inside the awaiter when called on the same
|
|
|
|
|
/// thread, but our LoginInteractiveAsync awaits across threads;
|
|
|
|
|
/// we use the post-await snapshot to keep this test deterministic.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task LoginInteractiveAsync_reports_Discovering_then_Success()
|
|
|
|
|
{
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
2026-06-27 12:47:52 +01:00
|
|
|
using var apiServer = new StubApiServer();
|
|
|
|
|
await apiServer.StartAsync();
|
|
|
|
|
|
|
|
|
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
|
|
|
|
var tokensPath = TokensPath();
|
|
|
|
|
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
|
|
|
|
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
|
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
|
|
|
|
|
var progress = new SyncProgress<OIDCLoginPhase>(reported);
|
2026-06-27 12:47:52 +01:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await LoginWithBrowserAsync(client, browser.CreateBrowser(), progress);
|
|
|
|
|
// SyncProgress captures reports synchronously — no flush needed.
|
|
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
Assert.Contains(OIDCLoginPhase.Discovering, reported);
|
|
|
|
|
Assert.Contains(OIDCLoginPhase.OpeningBrowser, reported);
|
|
|
|
|
Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
|
|
|
|
|
Assert.Equal(OIDCLoginPhase.Success, Last(reported));
|
2026-06-27 12:47:52 +01:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task LoginInteractiveAsync_reports_Error_when_browser_missing()
|
|
|
|
|
{
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
2026-06-27 12:47:52 +01:00
|
|
|
using var apiServer = new StubApiServer();
|
|
|
|
|
await apiServer.StartAsync();
|
|
|
|
|
|
|
|
|
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
|
|
|
|
var client = new YavscApiClient(settings, new TokenStore(TokensPath()));
|
2026-07-05 23:56:10 +01:00
|
|
|
var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
|
|
|
|
|
var progress = new SyncProgress<OIDCLoginPhase>(reported);
|
2026-06-27 12:47:52 +01:00
|
|
|
|
|
|
|
|
var original = Platform.CreateBrowser;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Platform.CreateBrowser = () => null; // simulate no browser wired up
|
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
2026-07-05 23:56:10 +01:00
|
|
|
() => client.LoginInteractiveAsync(progress, TestContext.Current.CancellationToken));
|
2026-06-27 12:47:52 +01:00
|
|
|
// SyncProgress captures reports synchronously — no flush needed.
|
|
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
Assert.Equal(OIDCLoginPhase.Error, Last(reported));
|
2026-06-27 12:47:52 +01:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Platform.CreateBrowser = original;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TrySilentLoginAsync_returns_false_when_no_bundle_on_disk()
|
|
|
|
|
{
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
2026-06-27 12:47:52 +01:00
|
|
|
using var apiServer = new StubApiServer();
|
|
|
|
|
await apiServer.StartAsync();
|
|
|
|
|
|
|
|
|
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
|
|
|
|
var tokensPath = TokensPath();
|
|
|
|
|
// Tokens file deliberately doesn't exist.
|
|
|
|
|
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
|
|
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
var ok = await client.TrySilentLoginAsync(null, TestContext.Current.CancellationToken);
|
2026-06-27 12:47:52 +01:00
|
|
|
Assert.False(ok);
|
|
|
|
|
Assert.False(client.HasValidSession);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TrySilentLoginAsync_returns_true_when_access_token_still_valid()
|
|
|
|
|
{
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
2026-06-27 12:47:52 +01:00
|
|
|
using var apiServer = new StubApiServer();
|
|
|
|
|
await apiServer.StartAsync();
|
|
|
|
|
|
|
|
|
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
|
|
|
|
var tokensPath = TokensPath();
|
|
|
|
|
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
|
|
|
|
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await LoginWithBrowserAsync(client, browser.CreateBrowser());
|
|
|
|
|
// Login fresh → access token is far from expiry.
|
2026-07-05 23:56:10 +01:00
|
|
|
var ok = await client.TrySilentLoginAsync(null, TestContext.Current.CancellationToken);
|
2026-06-27 12:47:52 +01:00
|
|
|
Assert.True(ok);
|
|
|
|
|
Assert.True(client.HasValidSession);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task TrySilentLoginAsync_returns_true_when_refresh_succeeds()
|
|
|
|
|
{
|
2026-07-05 23:56:10 +01:00
|
|
|
using var authority = await OIDCStubAuthority.StartAsync();
|
2026-06-27 12:47:52 +01:00
|
|
|
using var apiServer = new StubApiServer();
|
|
|
|
|
await apiServer.StartAsync();
|
|
|
|
|
|
|
|
|
|
var settings = BuildSettings(authority, apiServer.BaseUrl);
|
|
|
|
|
var tokensPath = TokensPath();
|
|
|
|
|
var store = new TokenStore(tokensPath);
|
|
|
|
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Bootstrap: login through one client to persist the
|
|
|
|
|
// bundle, then expire it on disk so the silent refresh
|
|
|
|
|
// path has to engage.
|
|
|
|
|
var firstClient = new YavscApiClient(settings, store);
|
|
|
|
|
await LoginWithBrowserAsync(firstClient, browser.CreateBrowser());
|
|
|
|
|
ExpireCachedAccessToken(tokensPath);
|
|
|
|
|
|
|
|
|
|
// Build a second API client to mirror the real boot
|
|
|
|
|
// path (YavscApiClient loads from the store in its
|
|
|
|
|
// constructor). Its in-memory _tokens snapshot now
|
|
|
|
|
// matches the disk: access expired, refresh still good.
|
|
|
|
|
var client = new YavscApiClient(settings, store);
|
|
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
var reported = new System.Collections.Generic.List<OIDCLoginPhase>();
|
|
|
|
|
var progress = new SyncProgress<OIDCLoginPhase>(reported);
|
2026-06-27 12:47:52 +01:00
|
|
|
|
2026-07-05 23:56:10 +01:00
|
|
|
var ok = await client.TrySilentLoginAsync(progress, TestContext.Current.CancellationToken);
|
2026-06-27 12:47:52 +01:00
|
|
|
Assert.True(ok, "silent refresh should succeed via the stub authority.");
|
2026-07-05 23:56:10 +01:00
|
|
|
Assert.Contains(OIDCLoginPhase.ExchangingCode, reported);
|
|
|
|
|
Assert.Equal(OIDCLoginPhase.Success, Last(reported));
|
2026-06-27 12:47:52 +01:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SKIPPED — see comment.
|
|
|
|
|
//
|
|
|
|
|
// We can't cover "TrySilentLoginAsync purges the store when the
|
|
|
|
|
// refresh token is rejected" with OidcStubAuthority: the stub's
|
|
|
|
|
// /connect/token endpoint is unconditional and hands out a fresh
|
|
|
|
|
// refresh token regardless of what the caller sends. To exercise
|
|
|
|
|
// the RefreshFailedException path we'd need an authority option
|
|
|
|
|
// to fail on a specific refresh-token string; until then the
|
|
|
|
|
// production refresh-failure path is covered manually (and by
|
|
|
|
|
// the structural guarantee that _store.Clear() runs in the catch
|
|
|
|
|
// block of ForceRefreshAsync when result.IsError).
|
|
|
|
|
//
|
|
|
|
|
// [Fact]
|
|
|
|
|
// public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... }
|
|
|
|
|
|
|
|
|
|
private static T Last<T>(System.Collections.Generic.List<T> list)
|
|
|
|
|
{
|
|
|
|
|
lock (list)
|
|
|
|
|
{
|
|
|
|
|
if (list.Count == 0)
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"IProgress<{typeof(T).Name}> never received any reports before the assertion.");
|
|
|
|
|
return list[list.Count - 1];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Synchronous <see cref="IProgress{T}"/> for tests. The BCL
|
|
|
|
|
/// <c>Progress<T></c> posts via <see cref="SynchronizationContext"/>,
|
|
|
|
|
/// which xUnit only drains between awaits in the test method —
|
|
|
|
|
/// long enough that two rapid <c>Report</c> calls in the same
|
|
|
|
|
/// await chain can produce an empty / partial list. A synchronous
|
|
|
|
|
/// proxy captures every report in the order it was made, which
|
|
|
|
|
/// is exactly the contract <c>YavscApiClient</c> relies on (it
|
|
|
|
|
/// never inspects the progress sink, it just calls <c>Report</c>).
|
|
|
|
|
/// </summary>
|
|
|
|
|
private sealed class SyncProgress<T> : IProgress<T>
|
|
|
|
|
{
|
|
|
|
|
private readonly System.Collections.Generic.List<T> _items;
|
|
|
|
|
private readonly object _gate = new();
|
|
|
|
|
public SyncProgress(System.Collections.Generic.List<T> sink) { _items = sink; }
|
|
|
|
|
public void Report(T value) { lock (_gate) _items.Add(value); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static void CorruptRefreshToken(string tokensPath)
|
|
|
|
|
{
|
|
|
|
|
// Kept as a helper even though the test that exercised it is
|
|
|
|
|
// currently disabled — see SKIPPED note above.
|
|
|
|
|
var json = File.ReadAllText(tokensPath);
|
|
|
|
|
var doc = JsonDocument.Parse(json);
|
|
|
|
|
var record = new RefreshTokenRecord(
|
|
|
|
|
AccessToken: doc.RootElement.GetProperty("AccessToken").GetString()!,
|
|
|
|
|
RefreshToken: "definitely-not-a-valid-refresh-token",
|
|
|
|
|
AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddMinutes(-5),
|
|
|
|
|
IdToken: doc.RootElement.TryGetProperty("IdToken", out var idt) ? idt.GetString() : null);
|
|
|
|
|
File.WriteAllText(tokensPath, JsonSerializer.Serialize(record));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// LoginWithBrowserAsync overload that also forwards a progress
|
|
|
|
|
/// sink to LoginInteractiveAsync. The default (no-progress)
|
|
|
|
|
/// overload stays for tests that don't care about phase events.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static async Task LoginWithBrowserAsync(
|
2026-07-05 23:56:10 +01:00
|
|
|
YavscApiClient client, IBrowser browser, IProgress<OIDCLoginPhase>? progress = null)
|
2026-06-27 12:47:52 +01:00
|
|
|
{
|
|
|
|
|
var original = Platform.CreateBrowser;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Platform.CreateBrowser = () => browser;
|
|
|
|
|
await client.LoginInteractiveAsync(progress);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
Platform.CreateBrowser = original;
|
|
|
|
|
}
|
|
|
|
|
}
|
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.
2026-06-23 21:25:17 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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();
|
|
|
|
|
}
|
|
|
|
|
}
|