test(postit): pin blogs scope on bearer, fix post-refactor tests

Two intertwined jobs here:

1. Diagnostic test for the production 401 we see when PostIt
   talks to Yavsc.Blogs. The hypothesis this test isolates:
   the access token sent on the wire is missing the 'blogs'
   scope that Yavsc.Blogs' BlogScope policy requires (see
   Yavsc.Blogs/Program.cs: RequireClaim(JwtClaimTypes.Scope,
   "blogs")). The test fakes a single HttpMessageHandler,
   captures the outbound bearer, decodes the JWT, and asserts
   the 'scope' claim contains 'blogs'. It does not stand up a
   server, an OIDC stub, or any network listener. Result: the
   scope is present in the access_token we construct, so the
   401 is not on the client side — most likely the IdP at
   Yavsc.Org is not issuing 'blogs' as a recognised scope.

2. Mechanical fix of the three test files that broke during
   the Settings model refactor (PostIt.Settings ->
   PostIt.ViewModels.Settings; ApiUrl -> BusinessApiUrl;
   Scopes/RedirectUri moved under Authentication;
   DefaultDesktopRedirectUri is on AuthenticationSettings in
   the global namespace). Also restored the BaseAddress
   setup that BlogApiClient does in production in
   LoginAndPersistAsync / the reloaded-client path of
   YavscApiClientTests, so the two integration tests that
   call CallAsync("posts") directly don't trip on
   'request URI must be absolute or BaseAddress must be set'.

Test status: 45 / 45 passing in PostIt.Tests.
This commit is contained in:
Paul Schneider 2026-07-08 19:35:43 +01:00
commit e9df13a477
6 changed files with 323 additions and 279 deletions

View file

@ -12,6 +12,7 @@ using System.Threading.Tasks;
using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser;
using PostIt.Services;
using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests;
@ -61,6 +62,12 @@ public class YavscApiClientTests
// Reload — YavscApiClient constructor reads the store.
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
// 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);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -111,16 +118,16 @@ public class YavscApiClientTests
[Fact]
public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
{
var settings = new PostIt.Settings
var settings = new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://127.0.0.1:5001",
ClientId = "postit-tests",
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
},
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
ApiUrl = "https://127.0.0.1:5003/api/v1",
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
};
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -155,24 +162,30 @@ public class YavscApiClientTests
// --- helpers --------------------------------------------------------
private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer,
ClientId = "postit-tests",
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" },
ApiUrl = apiBaseUrl,
BusinessApiUrl = apiBaseUrl
};
private static async Task<YavscApiClient> LoginAndPersistAsync(
PostIt.Settings settings, OIDCStubAuthority authority, string tokensPath)
Settings settings, OIDCStubAuthority authority, string tokensPath)
{
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
// 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);
// Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper.
await LoginWithBrowserAsync(client, browser.CreateBrowser());