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:
parent
d091f2c663
commit
f96d84dc5b
11 changed files with 505 additions and 179 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
315
src/PostIt.Tests/YavscApiClientTests.cs
Normal file
315
src/PostIt.Tests/YavscApiClientTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,11 @@ namespace PostIt.Desktop;
|
|||
/// <summary>
|
||||
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
|
||||
/// the shared <c>LoginPageViewModel</c> sees a working <c>IBrowser</c>
|
||||
/// (the loopback listener that captures the OIDC redirect) without
|
||||
/// referencing any platform-specific API from the shared library.
|
||||
/// — the custom-scheme browser that hands the OIDC callback off to the
|
||||
/// running instance through the named pipe. Desktop builds do NOT use
|
||||
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered
|
||||
/// with the OS at install time and the browser is whatever the user
|
||||
/// has configured to open it.
|
||||
/// </summary>
|
||||
internal static class PlatformBootstrap
|
||||
{
|
||||
|
|
@ -18,7 +21,10 @@ internal static class PlatformBootstrap
|
|||
if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0)
|
||||
return;
|
||||
|
||||
Platform.DefaultRedirectUri = Settings.DefaultLoopbackRedirectUri;
|
||||
|
||||
// Use the custom-scheme redirect on Desktop. Loopback is only
|
||||
// a fallback for platforms that cannot register postit://
|
||||
// (see Settings.DefaultLoopbackRedirectUri for that path).
|
||||
Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri;
|
||||
Platform.CustomScheme = "postit";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,20 +34,26 @@ public partial class App : Application
|
|||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var blog = BuildBlogClient(out var settings);
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainPageViewModel()
|
||||
DataContext = new MainPageViewModel(blog, settings)
|
||||
};
|
||||
}
|
||||
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
|
||||
{
|
||||
singleViewFactoryApplicationLifetime.MainViewFactory = () => new MainPage { DataContext = new MainPageViewModel() };
|
||||
singleViewFactoryApplicationLifetime.MainViewFactory = () =>
|
||||
{
|
||||
var blog = BuildBlogClient(out var settings);
|
||||
return new MainPage { DataContext = new MainPageViewModel(blog, settings) };
|
||||
};
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
|
||||
{
|
||||
var blog = BuildBlogClient(out var settings);
|
||||
singleViewPlatform.MainView = new MainPage
|
||||
{
|
||||
DataContext = new MainPageViewModel()
|
||||
DataContext = new MainPageViewModel(blog, settings)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -83,4 +89,20 @@ public partial class App : Application
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the (Settings, BlogApiClient) pair used by all UI
|
||||
/// lifetimes. A single TokenStore is shared so a login performed
|
||||
/// by the LoginPage is observable to the MainPage (and vice-versa)
|
||||
/// without going through disk on every API call.
|
||||
/// </summary>
|
||||
private static BlogApiClient BuildBlogClient(out Settings settings)
|
||||
{
|
||||
settings = new Settings();
|
||||
try { settings.Load().GetAwaiter().GetResult(); } catch { /* fall back to embedded defaults */ }
|
||||
var tokenStore = new TokenStore(System.IO.Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
|
||||
"PostIt", "tokens.json"));
|
||||
return new BlogApiClient(new YavscApiClient(settings, tokenStore));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +1,53 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using IdentityModel.OidcClient;
|
||||
using PostIt.Models;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class BlogApiClient : IDisposable
|
||||
/// <summary>
|
||||
/// High-level client for the Blog subsystem of the Yavsc API
|
||||
/// (deployed at <c>https://blogs.pschneider.fr</c>). All transport
|
||||
/// concerns — base URL, JSON serialisation, Bearer auth, silent
|
||||
/// refresh on 401, request body shaping — are delegated to
|
||||
/// <see cref="YavscApiClient"/>. This class is a thin DTO↔path
|
||||
/// mapper, nothing more.
|
||||
///
|
||||
/// The class is intentionally non-IDisposable: it does not own the
|
||||
/// <see cref="YavscApiClient"/> it depends on. Lifetimes are managed
|
||||
/// by the consumer (typically a singleton service registered with
|
||||
/// the application).
|
||||
/// </summary>
|
||||
public sealed class BlogApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _serializerOptions;
|
||||
private const string DefaultPathPrefix = "api/blog";
|
||||
|
||||
public BlogApiClient(string baseUrl, string? accessToken = null)
|
||||
: this(CreateHttpClient(baseUrl, accessToken))
|
||||
private readonly YavscApiClient _api;
|
||||
private readonly string _pathPrefix;
|
||||
|
||||
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
|
||||
{
|
||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
|
||||
}
|
||||
|
||||
public BlogApiClient(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
public Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
|
||||
=> _api.CallAsync<List<BlogPost>>(
|
||||
HttpMethod.Get,
|
||||
$"{_pathPrefix}?start={start}&take={take}",
|
||||
ct: ct);
|
||||
|
||||
private static HttpClient CreateHttpClient(string baseUrl, string? accessToken)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
|
||||
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
public Task<BlogPost?> GetPostAsync(long id, CancellationToken ct = default)
|
||||
=> _api.CallAsync<BlogPost?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
|
||||
|
||||
public async Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25)
|
||||
{
|
||||
var result = await _httpClient.GetFromJsonAsync<List<BlogPost>>($"api/blog?start={start}&take={take}", _serializerOptions).ConfigureAwait(false);
|
||||
return result ?? new List<BlogPost>();
|
||||
}
|
||||
public Task<BlogPost?> CreatePostAsync(BlogPost post, CancellationToken ct = default)
|
||||
=> _api.CallAsync<BlogPost?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
|
||||
|
||||
public Task<BlogPost?> GetPostAsync(long id)
|
||||
=> _httpClient.GetFromJsonAsync<BlogPost>($"api/blog/{id}", _serializerOptions);
|
||||
public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default)
|
||||
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
|
||||
|
||||
public async Task<BlogPost?> CreatePostAsync(BlogPost post)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("api/blog", post, _serializerOptions).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<BlogPost>(_serializerOptions).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpdatePostAsync(long id, BlogPost post)
|
||||
{
|
||||
var response = await _httpClient.PutAsJsonAsync($"api/blog/{id}", post, _serializerOptions).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task DeletePostAsync(long id)
|
||||
{
|
||||
var response = await _httpClient.DeleteAsync($"api/blog/{id}").ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
public Task DeletePostAsync(long id, CancellationToken ct = default)
|
||||
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public sealed class TokenStore
|
|||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
var json = File.ReadAllText(_path);
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
return JsonSerializer.Deserialize<RefreshTokenRecord>(json);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ namespace PostIt.Services;
|
|||
/// <see cref="BearerTokenHandler"/> only refreshes once even if many
|
||||
/// concurrent requests are in flight.
|
||||
/// </summary>
|
||||
public sealed class YavscApiClient : IAsyncDisposable
|
||||
public class YavscApiClient : IAsyncDisposable
|
||||
{
|
||||
// 60s of slack before the access_token's nominal expiry. Covers
|
||||
// network latency + JWT validation on the server side.
|
||||
|
|
@ -72,6 +72,17 @@ public sealed class YavscApiClient : IAsyncDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current access token, or null if no session is active.
|
||||
/// Surfaced so the LoginPageViewModel can mirror it onto its own
|
||||
/// observable property (and so the OIDC id_token / claims can be
|
||||
/// shown in the UI).
|
||||
/// </summary>
|
||||
public string? CurrentAccessToken => _tokens?.AccessToken;
|
||||
|
||||
/// <summary>The current OIDC id_token, or null.</summary>
|
||||
public string? CurrentIdToken => _tokens?.IdToken;
|
||||
|
||||
/// <summary>Force a new interactive login (PKCE). Throws on failure.</summary>
|
||||
public async Task LoginInteractiveAsync(CancellationToken ct = default)
|
||||
{
|
||||
|
|
@ -98,7 +109,7 @@ public sealed class YavscApiClient : IAsyncDisposable
|
|||
}
|
||||
|
||||
/// <summary>Call a JSON endpoint, transparently refreshing the token if needed.</summary>
|
||||
public async Task<T> CallAsync<T>(
|
||||
public virtual async Task<T> CallAsync<T>(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
object? body = null,
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ public partial class Settings : ObservableObject
|
|||
IStorageFolder? folder = null;
|
||||
|
||||
/// <summary>
|
||||
/// Default loopback redirect URI used for interactive PKCE login on desktop
|
||||
/// platforms. The corresponding <c>RedirectUri</c> must be registered for
|
||||
/// the PostIt client in IdentityServer.
|
||||
/// Loopback redirect URI alternative. Used by the test harness and
|
||||
/// available as a fallback if the running platform cannot register
|
||||
/// the default custom-scheme handler (<c>postit://callback</c>).
|
||||
/// Production builds prefer <see cref="Platform.DefaultRedirectUri"/>
|
||||
/// which routes through the OS-registered URI scheme (RFC 8252).
|
||||
/// </summary>
|
||||
public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/";
|
||||
|
||||
|
|
@ -33,6 +35,14 @@ public partial class Settings : ObservableObject
|
|||
/// </summary>
|
||||
public const string AndroidRedirectUri = "android://postit-signin";
|
||||
|
||||
/// <summary>
|
||||
/// Default custom-scheme redirect URI on Desktop. The OS routes the
|
||||
/// callback to the running PostIt instance via the named-pipe hand-off
|
||||
/// in <see cref="PostIt.Services.SingleInstance"/>. Production
|
||||
/// Desktop builds use this; loopback HTTP is only a fallback.
|
||||
/// </summary>
|
||||
public const string DefaultDesktopRedirectUri = "postit://callback";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial AuthenticationSettings Authentication { get; set; } = new();
|
||||
|
||||
|
|
|
|||
|
|
@ -146,19 +146,20 @@ public partial class LoginPageViewModel : ViewModelBase
|
|||
|
||||
/// <summary>
|
||||
/// Test-friendly constructor: caller supplies pre-loaded
|
||||
/// <paramref name="settings"/>, an optional pre-built
|
||||
/// <paramref name="apiClient"/>, and an optional
|
||||
/// <paramref name="settings"/>, an optional
|
||||
/// <paramref name="browserFactoryOverride"/> that bypasses the
|
||||
/// static <see cref="Platform"/> indirection.
|
||||
/// static <see cref="Platform"/> indirection, and an optional
|
||||
/// pre-built <paramref name="apiClient"/> for end-to-end
|
||||
/// scenarios where the test owns the wiring.
|
||||
/// </summary>
|
||||
public LoginPageViewModel(
|
||||
Settings settings,
|
||||
YavscApiClient? apiClient = null,
|
||||
Func<IBrowser?>? browserFactoryOverride = null)
|
||||
Func<IBrowser?>? browserFactoryOverride = null,
|
||||
YavscApiClient? apiClient = null)
|
||||
{
|
||||
Settings = settings;
|
||||
ApiClientOverride = apiClient;
|
||||
BrowserFactoryOverride = browserFactoryOverride;
|
||||
ApiClientOverride = apiClient;
|
||||
StatusMessage = "Ready";
|
||||
}
|
||||
|
||||
|
|
@ -217,6 +218,7 @@ public partial class LoginPageViewModel : ViewModelBase
|
|||
await LoginInteractiveCoreAsync(_api).ConfigureAwait(false);
|
||||
|
||||
IsBusy = false;
|
||||
AccessToken = _api.CurrentAccessToken;
|
||||
StatusMessage = "Interactive token acquired.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -1,35 +1,31 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Styling;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using IdentityModel.OidcClient;
|
||||
using IdentityModel.OidcClient.Browser;
|
||||
using PostIt.Models;
|
||||
using PostIt.Services;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace PostIt.ViewModels;
|
||||
|
||||
public partial class MainPageViewModel : ViewModelBase
|
||||
{
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string Title { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||
|
||||
public SettingsPageViewModel SettingsModel { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string StatusMessage { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SearchText { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string BearerToken { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<BlogPost> Posts { get; set; }
|
||||
|
||||
|
|
@ -47,62 +43,62 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
|
||||
[ObservableProperty]
|
||||
public partial Settings Settings { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// API surface that hits the Yavsc.Blogs deployment at
|
||||
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
|
||||
/// <c>App.axaml.cs</c> so the same client (and its token store)
|
||||
/// is shared with the login flow.
|
||||
/// </summary>
|
||||
public BlogApiClient BlogClient { get; }
|
||||
|
||||
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||
public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
|
||||
|
||||
public MainPageViewModel()
|
||||
/// <summary>
|
||||
/// Test-friendly constructor: caller supplies a pre-built
|
||||
/// <see cref="BlogApiClient"/>. Production code uses the
|
||||
/// (Settings, BlogApiClient) overload below.
|
||||
/// </summary>
|
||||
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
|
||||
{
|
||||
SearchText = string.Empty;
|
||||
Posts = new ObservableCollection<BlogPost>();
|
||||
FilteredPosts = new ObservableCollection<BlogPost>();
|
||||
SelectedPost = null;
|
||||
BearerToken = string.Empty;
|
||||
IsBusy = false;
|
||||
StatusMessage = "Ready";
|
||||
Settings = new Settings();
|
||||
Settings = settings ?? new Settings();
|
||||
Title = "PostIt";
|
||||
CurrentViewModel = this;
|
||||
SettingsModel = new SettingsPageViewModel();
|
||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));
|
||||
}
|
||||
|
||||
partial void OnSearchTextChanged(string value)
|
||||
{
|
||||
ApplyFilter();
|
||||
}
|
||||
partial void OnSearchTextChanged(string value) => ApplyFilter();
|
||||
|
||||
partial void OnSelectedPostChanged(BlogPost? value)
|
||||
{
|
||||
UpdateCommandStates();
|
||||
}
|
||||
partial void OnSelectedPostChanged(BlogPost? value) => UpdateCommandStates();
|
||||
|
||||
partial void OnIsBusyChanged(bool value)
|
||||
{
|
||||
UpdateCommandStates();
|
||||
}
|
||||
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
|
||||
|
||||
[RelayCommand]
|
||||
internal async Task LoadPosts()
|
||||
{
|
||||
await ExecuteAsync(async () =>
|
||||
{
|
||||
using var client = CreateClient();
|
||||
var posts = await client.GetPostsAsync();
|
||||
var posts = await BlogClient.GetPostsAsync();
|
||||
Posts.Clear();
|
||||
foreach (var post in posts.OrderByDescending(p => p.DateModified))
|
||||
{
|
||||
Posts.Add(post);
|
||||
}
|
||||
|
||||
ApplyFilter();
|
||||
StatusMessage = $"Loaded {Posts.Count} posts.";
|
||||
});
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
internal void Search()
|
||||
{
|
||||
ApplyFilter();
|
||||
}
|
||||
internal void Search() => ApplyFilter();
|
||||
|
||||
[RelayCommand]
|
||||
internal async Task Save()
|
||||
|
|
@ -115,13 +111,11 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
|
||||
await ExecuteAsync(async () =>
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
if (SelectedPost.Id == 0)
|
||||
{
|
||||
SelectedPost.DateCreated = DateTime.UtcNow;
|
||||
SelectedPost.DateModified = DateTime.UtcNow;
|
||||
var created = await client.CreatePostAsync(SelectedPost);
|
||||
var created = await BlogClient.CreatePostAsync(SelectedPost);
|
||||
if (created is not null)
|
||||
{
|
||||
SelectedPost = created;
|
||||
|
|
@ -131,7 +125,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
else
|
||||
{
|
||||
SelectedPost.DateModified = DateTime.UtcNow;
|
||||
await client.UpdatePostAsync(SelectedPost.Id, SelectedPost);
|
||||
await BlogClient.UpdatePostAsync(SelectedPost.Id, SelectedPost);
|
||||
StatusMessage = $"Saved post {SelectedPost.Id}.";
|
||||
}
|
||||
|
||||
|
|
@ -150,8 +144,7 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
|
||||
await ExecuteAsync(async () =>
|
||||
{
|
||||
using var client = CreateClient();
|
||||
await client.DeletePostAsync(SelectedPost.Id);
|
||||
await BlogClient.DeletePostAsync(SelectedPost.Id);
|
||||
StatusMessage = $"Deleted post {SelectedPost.Id}.";
|
||||
SelectedPost = null;
|
||||
await RefreshPostsAsync();
|
||||
|
|
@ -168,27 +161,23 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
DateCreated = DateTime.UtcNow,
|
||||
DateModified = DateTime.UtcNow
|
||||
};
|
||||
|
||||
StatusMessage = "New blog post ready.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
internal void OpenSettings()
|
||||
{
|
||||
// Appeler la méthode OpenSettings de la vue MainWindow
|
||||
CurrentViewModel = SettingsModel;
|
||||
}
|
||||
|
||||
private async Task RefreshPostsAsync()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
var posts = await client.GetPostsAsync();
|
||||
var posts = await BlogClient.GetPostsAsync();
|
||||
Posts.Clear();
|
||||
foreach (var post in posts.OrderByDescending(p => p.DateModified))
|
||||
{
|
||||
Posts.Add(post);
|
||||
}
|
||||
|
||||
ApplyFilter();
|
||||
|
||||
if (SelectedPost is not null)
|
||||
|
|
@ -234,51 +223,6 @@ public partial class MainPageViewModel : ViewModelBase
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs an interactive Authorization Code + PKCE login against the
|
||||
/// configured authority and stores the resulting access token in
|
||||
/// <see cref="BearerToken"/>. No client secret is sent; PKCE prevents
|
||||
/// authorization-code interception by relying on a per-request verifier
|
||||
/// generated locally and never leaving the device.
|
||||
/// </summary>
|
||||
/// <param name="browser">
|
||||
/// Platform-specific <see cref="IBrowser"/> implementation. On desktop
|
||||
/// pass a <c>LoopbackBrowser</c>; on Android a custom-scheme
|
||||
/// deep-link browser is required.
|
||||
/// </param>
|
||||
public async Task LoginAsync(IBrowser browser)
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "Signing in...";
|
||||
try
|
||||
{
|
||||
var client = new OidcClient(Settings.GetOidcClientOptions(browser));
|
||||
var loginResult = await client.LoginAsync(new LoginRequest()).ConfigureAwait(false);
|
||||
|
||||
if (loginResult.IsError)
|
||||
{
|
||||
StatusMessage = loginResult.Error ?? "Login failed.";
|
||||
return;
|
||||
}
|
||||
|
||||
BearerToken = loginResult.AccessToken ?? string.Empty;
|
||||
StatusMessage = string.IsNullOrEmpty(BearerToken)
|
||||
? "Login succeeded but no access token was returned."
|
||||
: "Signed in.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private BlogApiClient CreateClient()
|
||||
=> new BlogApiClient(Settings.ApiUrl, BearerToken);
|
||||
|
||||
private void UpdateCommandStates()
|
||||
{
|
||||
LoadPostsCommand.NotifyCanExecuteChanged();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue