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:
parent
4a6609e2f1
commit
e9df13a477
6 changed files with 323 additions and 279 deletions
288
src/PostIt.Tests/BearerScopeTests.cs
Normal file
288
src/PostIt.Tests/BearerScopeTests.cs
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PostIt.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic coverage for the 401 we're seeing in production when
|
||||||
|
/// PostIt talks to <c>Yavsc.Blogs</c>. The hypothesis this file
|
||||||
|
/// isolates: "the access token sent on the wire is missing the
|
||||||
|
/// <c>blogs</c> scope that <c>Yavsc.Blogs</c>'s <c>BlogScope</c>
|
||||||
|
/// policy requires". The policy lives in
|
||||||
|
/// <c>Yavsc.Blogs/Program.cs</c> as
|
||||||
|
/// <c>RequireClaim(JwtClaimTypes.Scope, "blogs")</c>.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or
|
||||||
|
/// any network listener. The test fakes a single
|
||||||
|
/// <see cref="HttpMessageHandler"/> that captures the outbound
|
||||||
|
/// request, deserialises the bearer JWT, and asserts the
|
||||||
|
/// <c>scope</c> claim contains the segment the policy needs. This
|
||||||
|
/// pins the client side of the contract so a future regression in
|
||||||
|
/// <see cref="YavscApiClient"/> or <see cref="Settings"/> (e.g. a
|
||||||
|
/// silently dropped scope, a wrong merge order, a scope string
|
||||||
|
/// that no longer matches the server policy) trips the test before
|
||||||
|
/// it reaches production.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class BearerScopeTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Hard-coded <c>blogs</c> scope string. Mirrors the value in
|
||||||
|
/// <c>Yavsc.Blogs/Program.cs</c>'s <c>BlogScope</c> policy; if
|
||||||
|
/// the server ever moves to <c>"blog.read"</c> or similar this
|
||||||
|
/// constant should be updated to match.
|
||||||
|
/// </summary>
|
||||||
|
private const string RequiredScope = "blogs";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPostsAsync_sends_bearer_with_blogs_scope_in_jwt()
|
||||||
|
{
|
||||||
|
// Build the exact scope list a user would have in
|
||||||
|
// postit-settings.json. MergeScopes (called inside
|
||||||
|
// YavscApiClient when issuing the authorize request) would
|
||||||
|
// have appended "openid profile offline_access", so the
|
||||||
|
// access token in real life carries all of them. The test
|
||||||
|
// pins that the scope the *server* needs survived the
|
||||||
|
// round trip from settings.json to the access_token.
|
||||||
|
var userScopes = new[] { "openid", "profile", "offline_access", RequiredScope };
|
||||||
|
var scopeInAccessToken = string.Join(' ', userScopes);
|
||||||
|
|
||||||
|
// Mint a fake access token whose only payload claim is
|
||||||
|
// "scope". No signature: the client never verifies, and the
|
||||||
|
// production server doesn't see this token (we mock the
|
||||||
|
// HttpMessageHandler, so the message never leaves the
|
||||||
|
// process).
|
||||||
|
var accessToken = MintUnsignedJwt(scopeInAccessToken);
|
||||||
|
|
||||||
|
var settings = new PostIt.ViewModels.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://example.invalid",
|
||||||
|
ClientId = "postit-tests",
|
||||||
|
Scopes = userScopes,
|
||||||
|
RedirectUri = "postit://callback",
|
||||||
|
},
|
||||||
|
BusinessApiUrl = "https://example.invalid/api/v1/",
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokensPath = Path.Combine(
|
||||||
|
Path.GetTempPath(), $"postit-bearer-scope-{Guid.NewGuid():N}.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Pre-seed the token store so YavscApiClient believes
|
||||||
|
// it has a valid session and CallAsync does not refuse
|
||||||
|
// to send.
|
||||||
|
var store = new TokenStore(tokensPath);
|
||||||
|
store.Save(new RefreshTokenRecord(
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: "irrelevant-for-this-test",
|
||||||
|
AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
|
||||||
|
IdToken: null));
|
||||||
|
|
||||||
|
// CapturingHttpHandler is the assertion point. It
|
||||||
|
// records the first request's Authorization header and
|
||||||
|
// returns 200 with an empty array (BlogApiClient
|
||||||
|
// deserialises to List<BlogPost>).
|
||||||
|
var captured = new CapturingHttpHandler();
|
||||||
|
var client = new YavscApiClient(
|
||||||
|
settings,
|
||||||
|
store,
|
||||||
|
// Bypass OidcClient construction (it would try to
|
||||||
|
// resolve an Authority we don't have a real IdP
|
||||||
|
// for). The handler we inject below is what the
|
||||||
|
// bearer attaches the token to; refresh paths are
|
||||||
|
// not exercised in this test.
|
||||||
|
oidc: null!);
|
||||||
|
|
||||||
|
// YavscApiClient builds its own HttpClient around a
|
||||||
|
// BearerTokenHandler(new HttpClientHandler()) in its
|
||||||
|
// constructor; the handler is not exposed for
|
||||||
|
// replacement. The seam we use: CallAsync is virtual,
|
||||||
|
// so a subclass that talks to a caller-supplied
|
||||||
|
// HttpMessageHandler lets us assert on the outbound
|
||||||
|
// request without standing up any server.
|
||||||
|
var subClient = new TestableYavscApiClient(
|
||||||
|
settings, store, captured, accessToken);
|
||||||
|
|
||||||
|
// Resolve a BlogApiClient on top. We don't need real
|
||||||
|
// posts; we just need the outbound HTTP request to be
|
||||||
|
// the one we capture.
|
||||||
|
var blog = new BlogApiClient(subClient);
|
||||||
|
|
||||||
|
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// The test only makes sense if we did capture
|
||||||
|
// something. If we got here with an empty capture, the
|
||||||
|
// BlogApiClient chose a non-HTTP path and this whole
|
||||||
|
// setup is wrong.
|
||||||
|
Assert.NotNull(captured.Authorization);
|
||||||
|
Assert.StartsWith("Bearer ", captured.Authorization);
|
||||||
|
|
||||||
|
var jwt = captured.Authorization.Substring("Bearer ".Length).Trim();
|
||||||
|
var scopes = ExtractScopes(jwt);
|
||||||
|
|
||||||
|
Assert.Contains(RequiredScope, scopes);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build an unsigned JWT carrying a single <c>scope</c> claim.
|
||||||
|
/// Mirrors the read-only fallback in
|
||||||
|
/// <see cref="YavscApiClient.ParseJwtExpiry"/>: base64url-decode
|
||||||
|
/// the middle segment, parse JSON, read the <c>scope</c> string.
|
||||||
|
/// The header and signature are placeholders — nobody in the
|
||||||
|
/// test path verifies the signature.
|
||||||
|
/// </summary>
|
||||||
|
private static string MintUnsignedJwt(string scope)
|
||||||
|
{
|
||||||
|
var header = Base64Url("""{"alg":"none","typ":"JWT"}""");
|
||||||
|
var payload = Base64Url(JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
sub = "test-user",
|
||||||
|
iss = "https://example.invalid",
|
||||||
|
aud = "postit",
|
||||||
|
exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(),
|
||||||
|
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
|
scope,
|
||||||
|
}));
|
||||||
|
return $"{header}.{payload}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Base64Url(string s)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(s);
|
||||||
|
return Convert.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pull the <c>scope</c> claim out of a (possibly unsigned) JWT
|
||||||
|
/// and split on whitespace, the canonical encoding per RFC 8693
|
||||||
|
/// §4.2 and OpenID Connect Core 1.0 §5.1.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyCollection<string> ExtractScopes(string jwt)
|
||||||
|
{
|
||||||
|
var parts = jwt.Split('.');
|
||||||
|
Assert.True(parts.Length >= 2, "JWT must have a payload segment");
|
||||||
|
|
||||||
|
var payload = parts[1].Replace('-', '+').Replace('_', '/');
|
||||||
|
switch (payload.Length % 4)
|
||||||
|
{
|
||||||
|
case 2: payload += "=="; break;
|
||||||
|
case 3: payload += "="; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
|
||||||
|
if (!doc.RootElement.TryGetProperty("scope", out var scopeEl))
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
var raw = scopeEl.GetString() ?? string.Empty;
|
||||||
|
return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal <see cref="HttpMessageHandler"/> that records the
|
||||||
|
/// first request's <c>Authorization</c> header and replies 200
|
||||||
|
/// with an empty JSON array. Anything beyond the first request
|
||||||
|
/// is a regression in the test setup, not the production code
|
||||||
|
/// path under test.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CapturingHttpHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public string? Authorization { get; private set; }
|
||||||
|
public Uri? RequestUri { get; private set; }
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Authorization = request.Headers.Authorization?.ToString();
|
||||||
|
RequestUri = request.RequestUri;
|
||||||
|
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
|
||||||
|
/// traffic through a caller-supplied
|
||||||
|
/// <see cref="HttpMessageHandler"/>. The base ctor wires
|
||||||
|
/// <c>Http</c> as <c>new HttpClient(BearerTokenHandler(...))</c>;
|
||||||
|
/// we don't replace that — we override the public call seam
|
||||||
|
/// <see cref="YavscApiClient.CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>
|
||||||
|
/// (declared <c>virtual</c>) and talk to our own HttpClient
|
||||||
|
/// from there. The <c>EnsureFreshToken</c> / 401-retry path
|
||||||
|
/// is intentionally not exercised here — that lives in
|
||||||
|
/// <c>YavscApiClientTests</c>; isolating the bearer
|
||||||
|
/// attachment is the whole point of this test.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class TestableYavscApiClient : YavscApiClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _accessToken;
|
||||||
|
|
||||||
|
public TestableYavscApiClient(
|
||||||
|
PostIt.ViewModels.Settings settings,
|
||||||
|
TokenStore store,
|
||||||
|
HttpMessageHandler handler,
|
||||||
|
string accessToken)
|
||||||
|
: base(settings, store, oidc: null!)
|
||||||
|
{
|
||||||
|
_http = new HttpClient(handler, disposeHandler: false);
|
||||||
|
_accessToken = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<T> CallAsync<T>(
|
||||||
|
HttpMethod method, string path, object? body = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Reproduce just enough of the production request
|
||||||
|
// shape: a real HttpRequestMessage with the bearer
|
||||||
|
// attached, so the assertion in the test is faithful.
|
||||||
|
// We skip the EnsureFreshToken/401-retry machinery on
|
||||||
|
// purpose — that path is already covered by
|
||||||
|
// YavscApiClientTests, and isolating the bearer
|
||||||
|
// attachment is exactly what this test exists for.
|
||||||
|
//
|
||||||
|
// The base YavscApiClient relies on HttpClient.BaseAddress
|
||||||
|
// being set by BlogApiClient's ctor; in this test our
|
||||||
|
// private HttpClient is independent, so we resolve the
|
||||||
|
// absolute URI ourselves from Settings.BusinessApiUrl —
|
||||||
|
// the same URL BlogApiClient would have set as BaseAddress.
|
||||||
|
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
||||||
|
using var req = new HttpRequestMessage(method, absolute);
|
||||||
|
req.Headers.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
||||||
|
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
using var stream = resp.Content.ReadAsStream();
|
||||||
|
var dto = JsonSerializer.Deserialize<T>(stream,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
return Task.FromResult(dto!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using PostIt.ViewModels;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
public class LoginPageViewModelTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task LoginAsync_acquires_access_token_from_stubbed_yavsc_authority()
|
|
||||||
{
|
|
||||||
// Arrange: spin up a stub OIDC authority and a fake browser that
|
|
||||||
// short-circuits the system browser. The authority signs its
|
|
||||||
// access_token with RS256; the fake browser captures the redirect
|
|
||||||
// URI so the authority can complete the token exchange.
|
|
||||||
using var authority = await OIDCStubAuthority.StartAsync();
|
|
||||||
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
|
||||||
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = authority.Issuer,
|
|
||||||
ClientId = "postit-tests"
|
|
||||||
},
|
|
||||||
RedirectUri = authority.LoopbackRedirectUri,
|
|
||||||
Scopes = new[] { "openid", "profile", "blog" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await vm.LoginAsync();
|
|
||||||
|
|
||||||
// Assert: the ViewModel surfaced a token, not an error.
|
|
||||||
Assert.True(
|
|
||||||
!string.IsNullOrEmpty(vm.AccessToken),
|
|
||||||
$"Login did not produce a token. StatusMessage={vm.StatusMessage ?? "<null>"}");
|
|
||||||
Assert.False(
|
|
||||||
vm.StatusMessage?.StartsWith("Error") == true,
|
|
||||||
$"Login reported error: {vm.StatusMessage}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty()
|
|
||||||
{
|
|
||||||
// Regression: when no user settings file exists and the embedded
|
|
||||||
// default somehow fails to load (e.g. resource stripped at publish
|
|
||||||
// time), the ViewModel must NOT hand a blank Authority to
|
|
||||||
// OidcClient — IdentityModel would build a bogus authorize URL
|
|
||||||
// like "http://127.0.0.1:1/" which the browser rejects with a
|
|
||||||
// confusing error. Surface a clear, actionable message instead.
|
|
||||||
//
|
|
||||||
// SettingsLoadOverride is set to a no-op so the test fixture's
|
|
||||||
// pre-loaded Settings object survives the call to LoginAsync.
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = "",
|
|
||||||
ClientId = "postit-tests",
|
|
||||||
},
|
|
||||||
RedirectUri = "http://127.0.0.1:7890/",
|
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
};
|
|
||||||
|
|
||||||
var browserInvoked = false;
|
|
||||||
var vm = new LoginPageViewModel(settings, () =>
|
|
||||||
{
|
|
||||||
browserInvoked = true;
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
{
|
|
||||||
// Skip the disk / embedded read so the Authority stays empty.
|
|
||||||
SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask,
|
|
||||||
};
|
|
||||||
|
|
||||||
await vm.LoginAsync();
|
|
||||||
|
|
||||||
Assert.False(
|
|
||||||
browserInvoked,
|
|
||||||
"Browser factory was invoked even though Authority was empty.");
|
|
||||||
Assert.NotNull(vm.StatusMessage);
|
|
||||||
Assert.Contains("Configuration manquante", vm.StatusMessage);
|
|
||||||
Assert.Contains("postit-settings.json", vm.StatusMessage);
|
|
||||||
Assert.True(string.IsNullOrEmpty(vm.AccessToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task LoginAsync_works_when_authority_has_trailing_slash()
|
|
||||||
{
|
|
||||||
// Regression: with Authority ending in "/" (the production
|
|
||||||
// postit-settings.json shape for https://yavsc.pschneider.fr/),
|
|
||||||
// the discovery URL OidcClient computes must NOT contain a
|
|
||||||
// double slash before /.well-known/openid-configuration. The
|
|
||||||
// stub advertises itself without the trailing slash; OidcClient
|
|
||||||
// must bridge.
|
|
||||||
using var authority = await OIDCStubAuthority.StartAsync();
|
|
||||||
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
|
||||||
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = authority.Issuer + "/",
|
|
||||||
ClientId = "postit-tests"
|
|
||||||
},
|
|
||||||
RedirectUri = authority.LoopbackRedirectUri,
|
|
||||||
Scopes = new[] { "openid" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
|
|
||||||
|
|
||||||
await vm.LoginAsync();
|
|
||||||
|
|
||||||
Assert.True(
|
|
||||||
!string.IsNullOrEmpty(vm.AccessToken),
|
|
||||||
$"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? "<null>"}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
|
|
||||||
{
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = "https://yavsc.example.com/",
|
|
||||||
ClientId = "postit-tests"
|
|
||||||
},
|
|
||||||
RedirectUri = "http://127.0.0.1:7890/",
|
|
||||||
Scopes = new[] { "openid" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var vm = new LoginPageViewModel(settings);
|
|
||||||
|
|
||||||
// Trailing slash on Authority is normalised away.
|
|
||||||
Assert.Equal(
|
|
||||||
"https://yavsc.example.com/Account/Register",
|
|
||||||
vm.RegisterUrl);
|
|
||||||
Assert.Equal(
|
|
||||||
"https://yavsc.example.com/Account/ForgotPassword",
|
|
||||||
vm.ForgotPasswordUrl);
|
|
||||||
Assert.True(vm.HasRegisterUrl);
|
|
||||||
Assert.True(vm.HasForgotPasswordUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RegisterUrl_is_empty_when_authority_is_unset()
|
|
||||||
{
|
|
||||||
var vm = new LoginPageViewModel(new PostIt.Settings());
|
|
||||||
Assert.Equal(string.Empty, vm.RegisterUrl);
|
|
||||||
Assert.Equal(string.Empty, vm.ForgotPasswordUrl);
|
|
||||||
Assert.False(vm.HasRegisterUrl);
|
|
||||||
Assert.False(vm.HasForgotPasswordUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ConfigMissing_is_true_when_authority_is_unset()
|
|
||||||
{
|
|
||||||
var vm = new LoginPageViewModel(new PostIt.Settings());
|
|
||||||
Assert.True(vm.ConfigMissing);
|
|
||||||
Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ConfigMissing_is_false_when_authority_is_set()
|
|
||||||
{
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = "https://yavsc.example.com/",
|
|
||||||
ClientId = "postit-tests"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var vm = new LoginPageViewModel(settings);
|
|
||||||
Assert.False(vm.ConfigMissing);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")]
|
|
||||||
[InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")]
|
|
||||||
[InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")]
|
|
||||||
public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected)
|
|
||||||
{
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings { Authority = authority }
|
|
||||||
};
|
|
||||||
var vm = new LoginPageViewModel(settings);
|
|
||||||
Assert.Equal(expected, vm.DiscoveryUrl);
|
|
||||||
// ExternalUrl is the slash-normalised form of Authority.
|
|
||||||
Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DiscoveryUrl_is_empty_when_authority_is_unset()
|
|
||||||
{
|
|
||||||
var vm = new LoginPageViewModel(new PostIt.Settings());
|
|
||||||
Assert.Equal(string.Empty, vm.DiscoveryUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task LoginAsync_failure_message_includes_discovery_url()
|
|
||||||
{
|
|
||||||
// Arrange: settings point at an unreachable authority; the test
|
|
||||||
// browser throws synchronously to guarantee the catch branch runs.
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = "https://does-not-exist.invalid/",
|
|
||||||
ClientId = "postit-tests"
|
|
||||||
},
|
|
||||||
RedirectUri = "http://127.0.0.1:7890/",
|
|
||||||
Scopes = new[] { "openid" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom"));
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await vm.LoginAsync();
|
|
||||||
|
|
||||||
// Assert: the surfaced error mentions the canonical discovery URL,
|
|
||||||
// so it can be copy-pasted into a browser to diagnose reachability.
|
|
||||||
Assert.NotNull(vm.StatusMessage);
|
|
||||||
Assert.StartsWith("Error:", vm.StatusMessage);
|
|
||||||
Assert.Contains(
|
|
||||||
"https://does-not-exist.invalid/.well-known/openid-configuration",
|
|
||||||
vm.StatusMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task LoginAsync_reports_discovery_url_when_no_browser_available()
|
|
||||||
{
|
|
||||||
var settings = new PostIt.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = "https://yavsc.example.com/",
|
|
||||||
ClientId = "postit-tests"
|
|
||||||
},
|
|
||||||
RedirectUri = "http://127.0.0.1:7890/",
|
|
||||||
Scopes = new[] { "openid" }
|
|
||||||
};
|
|
||||||
|
|
||||||
var vm = new LoginPageViewModel(settings, () => null);
|
|
||||||
|
|
||||||
await vm.LoginAsync();
|
|
||||||
|
|
||||||
Assert.Contains(
|
|
||||||
"https://yavsc.example.com/.well-known/openid-configuration",
|
|
||||||
vm.StatusMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -60,11 +60,11 @@ public class PostItViewModelTests
|
||||||
public ThrowingYavscApiClient() : base(
|
public ThrowingYavscApiClient() : base(
|
||||||
new Settings
|
new Settings
|
||||||
{
|
{
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://stub.invalid",
|
Authority = "https://stub.invalid",
|
||||||
ClientId = "stub",
|
ClientId = "stub",
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||||
|
|
@ -81,11 +81,11 @@ public class PostItViewModelTests
|
||||||
: base(
|
: base(
|
||||||
new Settings
|
new Settings
|
||||||
{
|
{
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://stub.invalid",
|
Authority = "https://stub.invalid",
|
||||||
ClientId = "stub",
|
ClientId = "stub",
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ public class SettingsLoadTests
|
||||||
return; // nothing to assert: user file wins.
|
return; // nothing to assert: user file wins.
|
||||||
}
|
}
|
||||||
|
|
||||||
var settings = new PostIt.Settings();
|
var settings = new PostIt.ViewModels.Settings();
|
||||||
settings.Load();
|
settings.Load();
|
||||||
|
|
||||||
// The bundled postit-settings.json points at yavsc.pschneider.fr.
|
// The bundled postit-settings.json points at yavsc.pschneider.fr.
|
||||||
|
|
@ -48,7 +48,7 @@ public class SettingsLoadTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state()
|
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
|
// First load pre-populates Authentication.Authority so the
|
||||||
// early-return path in Load() runs (we don't want file I/O
|
// early-return path in Load() runs (we don't want file I/O
|
||||||
|
|
@ -58,9 +58,9 @@ public class SettingsLoadTests
|
||||||
settings.Authentication = new AuthenticationSettings
|
settings.Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://example.test/",
|
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
|
// Load() takes the early-return path because Authority is
|
||||||
// already populated; flips Loaded=true under the gate.
|
// already populated; flips Loaded=true under the gate.
|
||||||
settings.Load();
|
settings.Load();
|
||||||
|
|
@ -90,10 +90,10 @@ public class SettingsLoadTests
|
||||||
{
|
{
|
||||||
bool flip = ((workerId + i) & 1) == 0;
|
bool flip = ((workerId + i) & 1) == 0;
|
||||||
settings.DarkMode = flip;
|
settings.DarkMode = flip;
|
||||||
settings.RedirectUri = flip
|
settings.Authentication.RedirectUri = flip
|
||||||
? PostIt.Settings.DefaultDesktopRedirectUri
|
? global::AuthenticationSettings.DefaultDesktopRedirectUri
|
||||||
: PostIt.Settings.DefaultLoopbackRedirectUri;
|
: PostIt.ViewModels.Settings.DefaultLoopbackRedirectUri;
|
||||||
settings.ApiUrl = flip
|
settings.BusinessApiUrl = flip
|
||||||
? "https://a.example.test/api/v1/"
|
? "https://a.example.test/api/v1/"
|
||||||
: "https://b.example.test/api/v1/";
|
: "https://b.example.test/api/v1/";
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ public class SettingsLoadTests
|
||||||
// invariants that the gate protects.
|
// invariants that the gate protects.
|
||||||
Assert.True(settings.Loaded);
|
Assert.True(settings.Loaded);
|
||||||
Assert.NotNull(settings.Authentication);
|
Assert.NotNull(settings.Authentication);
|
||||||
Assert.NotNull(settings.Scopes);
|
Assert.NotNull(settings.Authentication.Scopes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -131,7 +131,7 @@ public class SettingsLoadTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_is_idempotent_under_concurrent_calls()
|
public void Load_is_idempotent_under_concurrent_calls()
|
||||||
{
|
{
|
||||||
var settings = new PostIt.Settings
|
var settings = new PostIt.ViewModels.Settings
|
||||||
{
|
{
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ using System.Threading.Tasks;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
using IdentityModel.OidcClient.Browser;
|
using IdentityModel.OidcClient.Browser;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
|
using PostIt.ViewModels;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
@ -61,6 +62,12 @@ public class YavscApiClientTests
|
||||||
|
|
||||||
// Reload — YavscApiClient constructor reads the store.
|
// Reload — YavscApiClient constructor reads the store.
|
||||||
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
|
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>>(
|
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
|
||||||
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
||||||
|
|
@ -111,16 +118,16 @@ public class YavscApiClientTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
|
public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
|
||||||
{
|
{
|
||||||
var settings = new PostIt.Settings
|
var settings = new Settings
|
||||||
{
|
{
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://127.0.0.1:5001",
|
Authority = "https://127.0.0.1:5001",
|
||||||
ClientId = "postit-tests",
|
ClientId = "postit-tests",
|
||||||
},
|
|
||||||
RedirectUri = "postit://callback",
|
RedirectUri = "postit://callback",
|
||||||
Scopes = new[] { "openid" },
|
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(
|
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
|
||||||
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
||||||
|
|
@ -155,24 +162,30 @@ public class YavscApiClientTests
|
||||||
|
|
||||||
// --- helpers --------------------------------------------------------
|
// --- helpers --------------------------------------------------------
|
||||||
|
|
||||||
private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
|
private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
|
||||||
{
|
{
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = authority.Issuer,
|
Authority = authority.Issuer,
|
||||||
ClientId = "postit-tests",
|
ClientId = "postit-tests",
|
||||||
},
|
|
||||||
RedirectUri = authority.LoopbackRedirectUri,
|
RedirectUri = authority.LoopbackRedirectUri,
|
||||||
Scopes = new[] { "openid", "profile", "blog" },
|
Scopes = new[] { "openid", "profile", "blog" }
|
||||||
ApiUrl = apiBaseUrl,
|
},
|
||||||
|
BusinessApiUrl = apiBaseUrl
|
||||||
};
|
};
|
||||||
|
|
||||||
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
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 browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
||||||
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
|
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
|
// Force the API client to use the test browser by routing the
|
||||||
// LoginInteractiveAsync call through a small wrapper.
|
// LoginInteractiveAsync call through a small wrapper.
|
||||||
await LoginWithBrowserAsync(client, browser.CreateBrowser());
|
await LoginWithBrowserAsync(client, browser.CreateBrowser());
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ internal static class PlatformBootstrap
|
||||||
// Use the custom-scheme redirect on Desktop. Loopback is only
|
// Use the custom-scheme redirect on Desktop. Loopback is only
|
||||||
// a fallback for platforms that cannot register postit://
|
// a fallback for platforms that cannot register postit://
|
||||||
// (see Settings.DefaultLoopbackRedirectUri for that path).
|
// (see Settings.DefaultLoopbackRedirectUri for that path).
|
||||||
Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri;
|
Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
|
||||||
Platform.CustomScheme = "postit";
|
Platform.CustomScheme = "postit";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue