yavsc/src/test/Yavsc.Org.Tests/Mandatory/Remoting.cs

223 lines
9.4 KiB
C#
Raw Normal View History

2026-04-19 16:02:50 +01:00
using System.Security.Cryptography.X509Certificates;
using System.Net.Security;
Server: wire ProfileService so access tokens carry the name claim The custom ProfileService (Yavsc.Services.ProfileService) was never registered with IdentityServer, so IS8 fell back to its default profile service — which only emits claims listed in each API resource's UserClaims (RequestedClaimTypes). "name" was never among them, so every access token lacked "name" and GetUserName() resolved to null on all bearer resource hosts (Blogs/Api): the MyFiles upload 500 and the unreachable User.Identity.Name. The class was dead code; all prior tweaks to it (FullName→UserName) and to ServiceExtensions (MapInboundClaims=false) were necessary but insufficient until it was actually wired in. - HostingExtensions: AddProfileService<ProfileService>() after AddAspNetIdentity<ApplicationUser>(). - ProfileService: emit name=user.UserName, email, roles for any user grant whose client has "profile" in AllowedScopes, independent of the caller (the old ParsedScopes gate only fired for the id-token caller, never the access-token caller). - ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name, MapInboundClaims=false (keep short claim names as-emitted). - FileSystemHelpers: EnsureDestinationDirectory uses GetUserName() (the "name" claim) instead of User.Identity.Name for the per-user storage root. Tests: - TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror production shape (fixes FileSystemApiTests upload 500). - Remoting: PasswordToken_carries_name_claim_equal_to_login mints a real JWT via the IS token endpoint (password grant, openid profile) and asserts the access token carries name=login and GetUserName() resolves to it. - WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the inverse of Duende); allow openid+profile on the test client; re-seed the openid/profile identity resources in ReseedAuthTestData (a ResetAndMigrateDatabase wipes the shared in-memory store and previously left them gone → invalid_scope). - BillingServiceTests: [Collection("Yavsc Server")] to serialize with the host build that mutates the static BillingService/Config registries (pre-existing parallel race that the new test's timing surfaced). Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
2025-07-30 15:52:01 +01:00
using IdentityModel.Client;
using Xunit;
Server: wire ProfileService so access tokens carry the name claim The custom ProfileService (Yavsc.Services.ProfileService) was never registered with IdentityServer, so IS8 fell back to its default profile service — which only emits claims listed in each API resource's UserClaims (RequestedClaimTypes). "name" was never among them, so every access token lacked "name" and GetUserName() resolved to null on all bearer resource hosts (Blogs/Api): the MyFiles upload 500 and the unreachable User.Identity.Name. The class was dead code; all prior tweaks to it (FullName→UserName) and to ServiceExtensions (MapInboundClaims=false) were necessary but insufficient until it was actually wired in. - HostingExtensions: AddProfileService<ProfileService>() after AddAspNetIdentity<ApplicationUser>(). - ProfileService: emit name=user.UserName, email, roles for any user grant whose client has "profile" in AllowedScopes, independent of the caller (the old ParsedScopes gate only fired for the id-token caller, never the access-token caller). - ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name, MapInboundClaims=false (keep short claim names as-emitted). - FileSystemHelpers: EnsureDestinationDirectory uses GetUserName() (the "name" claim) instead of User.Identity.Name for the per-user storage root. Tests: - TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror production shape (fixes FileSystemApiTests upload 500). - Remoting: PasswordToken_carries_name_claim_equal_to_login mints a real JWT via the IS token endpoint (password grant, openid profile) and asserts the access token carries name=login and GetUserName() resolves to it. - WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the inverse of Duende); allow openid+profile on the test client; re-seed the openid/profile identity resources in ReseedAuthTestData (a ResetAndMigrateDatabase wipes the shared in-memory store and previously left them gone → invalid_scope). - BillingServiceTests: [Collection("Yavsc Server")] to serialize with the host build that mutates the static BillingService/Config registries (pre-existing parallel race that the new test's timing surfaced). Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
using Yavsc.Server.Helpers;
2018-07-15 00:29:06 +02:00
2026-06-19 19:59:15 +01:00
namespace Yavsc.Org.Tests
2018-07-15 00:29:06 +02:00
{
2025-07-16 00:47:50 +01:00
[Collection("Yavsc Server")]
2021-01-04 00:11:27 +00:00
[Trait("regression", "oui")]
2025-07-13 18:13:04 +01:00
public class Remoting : BaseTestContext, IClassFixture<WebServerFixture>
2018-07-15 00:29:06 +02:00
{
private readonly ITestOutputHelper _output;
2025-07-13 18:13:04 +01:00
public Remoting(WebServerFixture serverFixture, ITestOutputHelper output)
2018-08-02 12:28:13 +02:00
: base(output, serverFixture)
2018-07-21 16:15:54 +02:00
{
_output = output;
2018-07-21 16:15:54 +02:00
}
2018-07-15 00:29:06 +02:00
2025-07-30 15:52:01 +01:00
[Fact]
public async Task ObtainServiceToken()
2026-04-19 16:02:50 +01:00
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
2026-04-19 17:15:10 +01:00
2026-04-19 16:02:50 +01:00
HttpClient client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
2026-04-19 16:02:50 +01:00
var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
2026-04-19 16:02:50 +01:00
Scope = "test",
GrantType = "client_credentials"
}, cancellationToken);
2026-04-19 16:02:50 +01:00
if (response.IsError) throw new Exception(response.Error);
}
2025-07-15 17:35:14 +01:00
2026-04-19 17:15:10 +01:00
private static HttpClient NewHttpClient()
{
return new HttpClient(new BypassSslValidationHandler());
}
2025-07-16 00:47:50 +01:00
2026-04-19 16:02:50 +01:00
[Fact]
2025-07-30 15:52:01 +01:00
public async Task ObtainResourceOwnerPasswordToken()
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
2026-04-19 17:15:10 +01:00
2026-03-09 02:07:09 +00:00
var client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
2018-07-15 00:29:06 +02:00
2025-07-30 15:52:01 +01:00
var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
2026-04-19 16:02:50 +01:00
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)),
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)),
2026-04-19 16:02:50 +01:00
Scope = "test",
Parameters =
2018-08-02 12:28:13 +02:00
{
2025-07-30 15:52:01 +01:00
{ "acr_values", "tenant:custom_account_store1 foo bar quux" }
}
}, cancellationToken);
2025-07-30 15:52:01 +01:00
if (response.IsError) throw new Exception(response.Error);
2018-07-15 00:29:06 +02:00
}
2018-08-02 12:28:13 +02:00
Server: wire ProfileService so access tokens carry the name claim The custom ProfileService (Yavsc.Services.ProfileService) was never registered with IdentityServer, so IS8 fell back to its default profile service — which only emits claims listed in each API resource's UserClaims (RequestedClaimTypes). "name" was never among them, so every access token lacked "name" and GetUserName() resolved to null on all bearer resource hosts (Blogs/Api): the MyFiles upload 500 and the unreachable User.Identity.Name. The class was dead code; all prior tweaks to it (FullName→UserName) and to ServiceExtensions (MapInboundClaims=false) were necessary but insufficient until it was actually wired in. - HostingExtensions: AddProfileService<ProfileService>() after AddAspNetIdentity<ApplicationUser>(). - ProfileService: emit name=user.UserName, email, roles for any user grant whose client has "profile" in AllowedScopes, independent of the caller (the old ParsedScopes gate only fired for the id-token caller, never the access-token caller). - ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name, MapInboundClaims=false (keep short claim names as-emitted). - FileSystemHelpers: EnsureDestinationDirectory uses GetUserName() (the "name" claim) instead of User.Identity.Name for the per-user storage root. Tests: - TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror production shape (fixes FileSystemApiTests upload 500). - Remoting: PasswordToken_carries_name_claim_equal_to_login mints a real JWT via the IS token endpoint (password grant, openid profile) and asserts the access token carries name=login and GetUserName() resolves to it. - WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the inverse of Duende); allow openid+profile on the test client; re-seed the openid/profile identity resources in ReseedAuthTestData (a ResetAndMigrateDatabase wipes the shared in-memory store and previously left them gone → invalid_scope). - BillingServiceTests: [Collection("Yavsc Server")] to serialize with the host build that mutates the static BillingService/Config registries (pre-existing parallel race that the new test's timing surfaced). Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:42:54 +01:00
/// <summary>
/// Regression for the MyFiles upload 500 / null
/// <c>User.Identity.Name</c>: the access token minted by the
/// real IdentityServer (resource-owner password grant) must
/// carry a <c>name</c> claim whose value is the user's login
/// (<c>UserName</c>), and <see cref="UserHelpers.GetUserName"/>
/// — which reads <c>FindFirstValue("name")</c> — must resolve
/// to it on a principal built the way
/// <c>AddYavscJwtBearer</c> builds one
/// (<c>MapInboundClaims=false</c>, <c>NameClaimType="name"</c>).
///
/// <para>Prerequisites wired by <see cref="WebServerFixture"/>:
/// the test client's <c>AllowedScopes</c> include
/// <c>openid</c>+<c>profile</c>, and the standard identity
/// resources are seeded by the production pipeline. Requesting
/// <c>openid profile</c> makes <c>ProfileService</c> emit
/// <c>name</c> = <c>user.UserName</c>.</para>
/// </summary>
[Fact]
public async Task PasswordToken_carries_name_claim_equal_to_login()
{
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
var client = NewHttpClient();
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)),
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)),
// "openid profile" triggers ProfileService to emit the
// "name" claim (user.UserName). Without profile, the
// token carries only sub + scopes and GetUserName()
// resolves to null — the exact regression behind the
// MyFiles upload 500. "test" is the API scope, so a real
// JWT access token (not just an id_token) is issued.
Scope = "openid profile test"
}, cancellationToken);
Assert.False(response.IsError,
$"Token request failed: {response.Error} ({response.ErrorDescription})");
// Decode the access token straight from the payload so short
// claim names ("name", "sub") stay as-emitted — mirrors
// AddYavscJwtBearer with MapInboundClaims=false.
var jwt = new JwtSecurityToken(response.AccessToken);
var nameClaim = jwt.Claims.FirstOrDefault(c => c.Type == "name");
Assert.True(nameClaim is not null,
"access_token has no 'name' claim; ProfileService did not emit it " +
"(is the 'profile' scope in the client's AllowedScopes and requested?).");
Assert.Equal(_serverFixture.TestingUserName, nameClaim!.Value);
// Rebuild the principal the way a bearer consumer host does
// and exercise the real GetUserName() helper used by the
// file-system / avatar paths.
var identity = new ClaimsIdentity(
jwt.Claims, authenticationType: "Bearer",
nameType: "name", roleType: null);
var principal = new ClaimsPrincipal(identity);
Assert.Equal(_serverFixture.TestingUserName, principal.GetUserName());
}
[Fact]
public async Task GetSignin_returns_a_page()
{
using var client = new HttpClient(new BypassSslValidationHandler())
{
BaseAddress = new Uri(this._serverFixture.HttpsAuthority ?? throw new InvalidOperationException("Missing HttpsAuthority"))
};
await AssertResponds(client, "/signin");
}
[Fact]
public async Task GetOpenIdConfiguration_returns_ok()
{
2026-09-12 16:49:36 +01:00
using var client = CreateHttpClient();
var response = await GetRaw(client, "/.well-known/openid-configuration");
2026-09-12 16:49:36 +01:00
var payload = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken
);
Assert.True(
response.IsSuccessStatusCode,
$"GET /.well-known/openid-configuration returned {(int)response.StatusCode} {response.StatusCode}. Body: {payload}");
}
2025-07-16 00:47:50 +01:00
public static IEnumerable<object[]> GetLoginIntentData()
2025-07-13 18:13:04 +01:00
{
2025-07-16 00:47:50 +01:00
return new object[][] { new object[] { "testuser", "test" } };
2025-07-13 18:13:04 +01:00
}
2025-07-30 15:52:01 +01:00
private async Task<string> ResolveTokenEndpointAsync(HttpClient client, string serverUrl, CancellationToken cancellationToken)
{
var disco = await client.GetDiscoveryDocumentAsync(serverUrl, cancellationToken);
if (!disco.IsError && !string.IsNullOrWhiteSpace(disco.TokenEndpoint))
{
return disco.TokenEndpoint;
}
// Some full-suite runs intermittently return 500 on the OIDC
// discovery document while /connect/token remains available.
var fallback = new Uri(new Uri(serverUrl), "/connect/token").ToString();
_output.WriteLine($"WARNING: OIDC discovery failed ({disco.Error}). Fallback token endpoint: {fallback}");
return fallback;
}
private string GetServerUrl()
{
return RequireNonEmpty(_serverFixture.SiteSettings?.Authority, "SiteSettings.Authority");
}
private static string RequireNonEmpty(string? value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"Missing required test setting: {name}");
}
return value;
}
2018-07-15 00:29:06 +02:00
}
2026-03-09 02:07:09 +00:00
2026-04-19 17:15:10 +01:00
internal class BypassSslValidationHandler : HttpClientHandler
2026-03-09 02:07:09 +00:00
{
2026-04-19 17:15:10 +01:00
public BypassSslValidationHandler()
{
// Override validation for this handler only
ServerCertificateCustomValidationCallback = ValidateCertificate;
}
private bool ValidateCertificate(
HttpRequestMessage request,
X509Certificate2? certificate,
X509Chain? chain,
SslPolicyErrors errors)
{
// Accept all certificates (bypass validation)
return true;
}
2026-03-09 02:07:09 +00:00
}
2026-03-09 02:07:09 +00:00
}