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

@ -27,7 +27,7 @@ public class SettingsLoadTests
return; // nothing to assert: user file wins.
}
var settings = new PostIt.Settings();
var settings = new PostIt.ViewModels.Settings();
settings.Load();
// The bundled postit-settings.json points at yavsc.pschneider.fr.
@ -48,7 +48,7 @@ public class SettingsLoadTests
[Fact]
public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state()
{
var settings = new PostIt.Settings();
var settings = new PostIt.ViewModels.Settings();
// First load pre-populates Authentication.Authority so the
// early-return path in Load() runs (we don't want file I/O
@ -58,9 +58,9 @@ public class SettingsLoadTests
settings.Authentication = new AuthenticationSettings
{
Authority = "https://example.test/",
ClientId = "postit-tests"
ClientId = "postit-tests",
Scopes = new[] { "openid" },
};
settings.Scopes = new[] { "openid" };
// Load() takes the early-return path because Authority is
// already populated; flips Loaded=true under the gate.
settings.Load();
@ -90,10 +90,10 @@ public class SettingsLoadTests
{
bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip;
settings.RedirectUri = flip
? PostIt.Settings.DefaultDesktopRedirectUri
: PostIt.Settings.DefaultLoopbackRedirectUri;
settings.ApiUrl = flip
settings.Authentication.RedirectUri = flip
? global::AuthenticationSettings.DefaultDesktopRedirectUri
: PostIt.ViewModels.Settings.DefaultLoopbackRedirectUri;
settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/";
@ -102,7 +102,7 @@ public class SettingsLoadTests
// invariants that the gate protects.
Assert.True(settings.Loaded);
Assert.NotNull(settings.Authentication);
Assert.NotNull(settings.Scopes);
Assert.NotNull(settings.Authentication.Scopes);
}
}
catch (Exception ex)
@ -131,7 +131,7 @@ public class SettingsLoadTests
[Fact]
public void Load_is_idempotent_under_concurrent_calls()
{
var settings = new PostIt.Settings
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{