83856b7c27
Add ZB.MOM.WW.Auth.AspNetCore package ref to Security project (version 0.1.1
from central PM). Alias JwtTokenService.UsernameClaimType and DisplayNameClaimType
to ZbClaimTypes.Username ("zb:username") and ZbClaimTypes.DisplayName ("zb:displayname")
so every mint/read site inherits the canonical spelling. AuthEndpoints login path now
emits ZbClaimTypes.Name (= ClaimTypes.Name, populates Identity.Name) instead of
ClaimTypes.NameIdentifier (no other read site used it), and references ZbClaimTypes.Role
(= ClaimTypes.Role) for role claims so [Authorize(Roles=...)] continues to resolve.
Cookie hardening now flows through ZbCookieDefaults.Apply (sets HttpOnly, SameSite=Strict,
SlidingExpiration, SecurePolicy, ExpireTimeSpan) followed by opts.Cookie.Name = v.Name to
preserve the OtOpcUa-specific "ZB.MOM.WW.OtOpcUa.Auth" cookie name. Two new tests added
to AuthEndpointsIntegrationTests assert canonical ZbClaimTypes on the cookie principal and
canonical zb: keys in the JWT payload; all 35 security tests green.
532 lines
24 KiB
C#
532 lines
24 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
|
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
|
|
|
/// <summary>
|
|
/// End-to-end auth contract test: exercises <c>AddOtOpcUaAuth + MapOtOpcUaAuth</c>
|
|
/// through an in-memory <c>TestServer</c>. Scope is the auth surface — not the fused
|
|
/// <c>OtOpcUa.Host</c> bootstrap (that would entail Akka cluster + role gating, which
|
|
/// belongs in the multi-node Task 58 harness). Stub <see cref="ILdapAuthService"/>
|
|
/// drives the auth outcomes; <see cref="OtOpcUaConfigDbContext"/> uses EF in-memory so
|
|
/// DataProtection can persist keys.
|
|
/// </summary>
|
|
public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
|
{
|
|
private IHost _host = null!;
|
|
private TestServer _server = null!;
|
|
private readonly StubLdapGroupRoleMappingService _roleMappings = new();
|
|
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
/// <summary>Initializes the test host and server.</summary>
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
var dbName = $"auth-int-tests-{Guid.NewGuid():N}";
|
|
|
|
_host = new HostBuilder()
|
|
.ConfigureWebHost(web =>
|
|
{
|
|
web.UseTestServer();
|
|
web.ConfigureServices(services =>
|
|
{
|
|
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt =>
|
|
opt.UseInMemoryDatabase(dbName));
|
|
services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
|
opt.UseInMemoryDatabase(dbName));
|
|
|
|
services.AddRouting();
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Security:Jwt:SigningKey"] = "test-signing-key-with-at-least-32-bytes-of-utf8-content",
|
|
["Security:Jwt:Issuer"] = "otopcua-test",
|
|
["Security:Jwt:Audience"] = "otopcua-test",
|
|
// GroupToRole baseline bound onto LdapOptions: the production
|
|
// OtOpcUaGroupRoleMapper resolves "ConfigViewer" from the LDAP group
|
|
// "ReadOnly". This exercises the real mapper path — the stub no longer
|
|
// pre-populates roles, so ConfigViewer can only come from the mapper.
|
|
["Security:Ldap:GroupToRole:ReadOnly"] = "ConfigViewer",
|
|
}).Build();
|
|
services.AddOtOpcUaAuth(configuration);
|
|
services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
|
|
// The login handler now resolves the DB role-map service via DI to merge
|
|
// DB-backed grants on top of the appsettings baseline. Register the stub so
|
|
// the minimal-API handler can be constructed; tests drive its rows.
|
|
services.AddSingleton<ILdapGroupRoleMappingService>(_roleMappings);
|
|
});
|
|
web.Configure(app =>
|
|
{
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseEndpoints(e =>
|
|
{
|
|
e.MapOtOpcUaAuth();
|
|
// Protected root used by AuthChallengeTests below — exercises the cookie
|
|
// scheme's challenge heuristic without depending on the full Razor host.
|
|
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
|
|
// Canonical-claims probe: returns all claim types+values from the cookie
|
|
// principal so tests can assert the canonical ZbClaimTypes vocabulary.
|
|
e.MapGet("/auth/whoami", (HttpContext ctx) =>
|
|
{
|
|
var claims = ctx.User.Claims
|
|
.Select(c => new { c.Type, c.Value })
|
|
.ToArray();
|
|
return Results.Ok(claims);
|
|
}).RequireAuthorization();
|
|
});
|
|
});
|
|
})
|
|
.Build();
|
|
|
|
await _host.StartAsync(Ct);
|
|
_server = _host.GetTestServer();
|
|
}
|
|
|
|
/// <summary>Disposes the test host and server.</summary>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _host.StopAsync(TestContext.Current.CancellationToken);
|
|
_host.Dispose();
|
|
}
|
|
|
|
private HttpClient NewClient() => _server.CreateClient();
|
|
|
|
/// <summary>Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
|
|
/// Used by challenge tests so we can assert on the 302 + Location directly.</summary>
|
|
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
|
|
{
|
|
BaseAddress = _server.BaseAddress,
|
|
};
|
|
|
|
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
|
|
[Fact]
|
|
public async Task Login_with_valid_credentials_returns_204_and_sets_cookie()
|
|
{
|
|
var client = NewClient();
|
|
var response = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth="));
|
|
}
|
|
|
|
/// <summary>Tests that login with invalid credentials returns 401.</summary>
|
|
[Fact]
|
|
public async Task Login_with_invalid_credentials_returns_401()
|
|
{
|
|
var client = NewClient();
|
|
var response = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "wrong-password"), Ct);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
/// <summary>Tests that login when LDAP throws returns 503.</summary>
|
|
[Fact]
|
|
public async Task Login_when_ldap_throws_returns_503()
|
|
{
|
|
var client = NewClient();
|
|
var response = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
|
|
}
|
|
|
|
/// <summary>Tests that ping anonymous returns 401.</summary>
|
|
[Fact]
|
|
public async Task Ping_anonymous_returns_401()
|
|
{
|
|
var client = NewClient();
|
|
var response = await client.GetAsync("/auth/ping", Ct);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
/// <summary>Tests that ping after cookie login returns 200.</summary>
|
|
[Fact]
|
|
public async Task Ping_after_cookie_login_returns_200()
|
|
{
|
|
var client = NewClient();
|
|
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
loginResponse.EnsureSuccessStatusCode();
|
|
|
|
var ping = new HttpRequestMessage(HttpMethod.Get, "/auth/ping");
|
|
AttachCookies(ping, loginResponse);
|
|
var response = await client.SendAsync(ping, Ct);
|
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
}
|
|
|
|
/// <summary>Tests that token after cookie login returns jwt.</summary>
|
|
[Fact]
|
|
public async Task Token_after_cookie_login_returns_jwt()
|
|
{
|
|
var client = NewClient();
|
|
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
loginResponse.EnsureSuccessStatusCode();
|
|
|
|
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
|
AttachCookies(tokenReq, loginResponse);
|
|
var response = await client.SendAsync(tokenReq, Ct);
|
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
|
var token = payload.GetProperty("token").GetString();
|
|
token.ShouldNotBeNullOrEmpty();
|
|
token!.Split('.').Length.ShouldBe(3);
|
|
}
|
|
|
|
/// <summary>A system-wide DB row for a group the user holds grants an extra role on top of
|
|
/// the appsettings baseline; the merged role surfaces in the issued JWT's Role claims.</summary>
|
|
[Fact]
|
|
public async Task Login_merges_db_role_grant_into_claims()
|
|
{
|
|
// StubLdapAuthService returns Groups ["ReadOnly"] with empty Roles (the real production
|
|
// shape). The mapper resolves the appsettings baseline "ReadOnly" → ConfigViewer, then a
|
|
// system-wide DB row maps "ReadOnly" → FleetAdmin, so the merged set is both.
|
|
_roleMappings.Rows.Add(new LdapGroupRoleMapping
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
LdapGroup = "ReadOnly",
|
|
Role = AdminRole.FleetAdmin,
|
|
IsSystemWide = true,
|
|
ClusterId = null,
|
|
});
|
|
|
|
var client = NewClient();
|
|
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
loginResponse.EnsureSuccessStatusCode();
|
|
|
|
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
|
AttachCookies(tokenReq, loginResponse);
|
|
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
|
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
|
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
|
|
roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved
|
|
roles.ShouldContain("FleetAdmin"); // DB grant merged in
|
|
}
|
|
|
|
/// <summary>Fail-closed (review I3): when the role mapper throws on the real production path
|
|
/// (the auth result carries no pre-resolved roles — roles come only from the mapper), sign-in
|
|
/// still SUCCEEDS but the user is granted ZERO role claims. They are authenticated (can prove
|
|
/// identity) yet authorized for nothing role-gated until the mapper recovers — the safe
|
|
/// fail-closed behaviour, not a fail-open with a stale role set.</summary>
|
|
[Fact]
|
|
public async Task Login_when_role_mapper_throws_signs_in_with_no_role_claims()
|
|
{
|
|
// Simulate a mapper fault on the real path. The whole MapAsync throws (the appsettings
|
|
// baseline is computed inside the mapper, so it does NOT survive the throw): the login
|
|
// endpoint falls back to result.Roles, which is empty on the real LDAP path.
|
|
_roleMappings.Throws = true;
|
|
|
|
var client = NewClient();
|
|
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
|
|
// Login proceeds despite the simulated DB outage — authenticated.
|
|
loginResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
|
AttachCookies(tokenReq, loginResponse);
|
|
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
|
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
// No role claims at all — fail closed.
|
|
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
|
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
|
|
roles.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Parses the payload segment of a JWT and returns it as a <see cref="JsonElement"/>.</summary>
|
|
private static JsonElement JwtPayloadJson(string jwt)
|
|
{
|
|
var payloadSegment = jwt.Split('.')[1];
|
|
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
|
|
padded = (padded.Length % 4) switch
|
|
{
|
|
2 => padded + "==",
|
|
3 => padded + "=",
|
|
_ => padded,
|
|
};
|
|
return JsonDocument.Parse(Convert.FromBase64String(padded)).RootElement;
|
|
}
|
|
|
|
/// <summary>Extracts the "Role" claim values from a JWT's payload segment.</summary>
|
|
private static IReadOnlyList<string> JwtRoleClaims(string jwt)
|
|
{
|
|
var payloadSegment = jwt.Split('.')[1];
|
|
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
|
|
switch (padded.Length % 4)
|
|
{
|
|
case 2: padded += "=="; break;
|
|
case 3: padded += "="; break;
|
|
}
|
|
var json = JsonDocument.Parse(Convert.FromBase64String(padded));
|
|
if (!json.RootElement.TryGetProperty("Role", out var roleProp)) return [];
|
|
return roleProp.ValueKind == JsonValueKind.Array
|
|
? [.. roleProp.EnumerateArray().Select(e => e.GetString()!)]
|
|
: [roleProp.GetString()!];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Task 1.5 — canonical claims contract: after a successful cookie login the authenticated
|
|
/// principal MUST carry the canonical ZbClaimTypes vocabulary:
|
|
/// <list type="bullet">
|
|
/// <item><see cref="ZbClaimTypes.Name"/> (= ClaimTypes.Name) so Identity.Name resolves.</item>
|
|
/// <item><see cref="ZbClaimTypes.Username"/> (= "zb:username") — login username.</item>
|
|
/// <item><see cref="ZbClaimTypes.DisplayName"/> (= "zb:displayname") — human-friendly name.</item>
|
|
/// <item><see cref="ZbClaimTypes.Role"/> (= ClaimTypes.Role) — at least one role claim.</item>
|
|
/// </list>
|
|
/// Also asserts that the old short-name literals "Username" and "DisplayName" are NOT emitted
|
|
/// (the pre-Task-1.5 strings that would indicate the migration was incomplete).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Login_emits_canonical_ZbClaimTypes_on_cookie_principal()
|
|
{
|
|
// Arrange — seed a DB role so the mapper produces a role claim.
|
|
_roleMappings.Rows.Add(new LdapGroupRoleMapping
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
LdapGroup = "ReadOnly",
|
|
Role = AdminRole.FleetAdmin,
|
|
IsSystemWide = true,
|
|
ClusterId = null,
|
|
});
|
|
|
|
var client = NewClient();
|
|
|
|
// Act — login.
|
|
var loginResp = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
loginResp.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
// Call the whoami probe to read back the cookie principal's claims.
|
|
var whoamiReq = new HttpRequestMessage(HttpMethod.Get, "/auth/whoami");
|
|
AttachCookies(whoamiReq, loginResp);
|
|
var whoamiResp = await client.SendAsync(whoamiReq, Ct);
|
|
whoamiResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
var claims = (await whoamiResp.Content.ReadFromJsonAsync<ClaimDto[]>(Ct))!;
|
|
|
|
// Assert — canonical name claim (ClaimTypes.Name URI) so Identity.Name resolves.
|
|
claims.ShouldContain(c => c.Type == ZbClaimTypes.Name && c.Value == "alice",
|
|
$"Expected {ZbClaimTypes.Name} claim with value 'alice'");
|
|
|
|
// Assert — canonical username claim ("zb:username").
|
|
claims.ShouldContain(c => c.Type == ZbClaimTypes.Username && c.Value == "alice",
|
|
$"Expected {ZbClaimTypes.Username} claim with value 'alice'");
|
|
|
|
// Assert — canonical display-name claim ("zb:displayname").
|
|
claims.ShouldContain(c => c.Type == ZbClaimTypes.DisplayName && c.Value == "Alice User",
|
|
$"Expected {ZbClaimTypes.DisplayName} claim with value 'Alice User'");
|
|
|
|
// Assert — at least one role claim using canonical ZbClaimTypes.Role (= ClaimTypes.Role).
|
|
claims.ShouldContain(c => c.Type == ZbClaimTypes.Role,
|
|
$"Expected at least one {ZbClaimTypes.Role} claim");
|
|
|
|
// Assert — old pre-Task-1.5 short literals must NOT appear.
|
|
claims.ShouldNotContain(c => c.Type == "Username",
|
|
"Old 'Username' literal must not be emitted after Task 1.5 migration");
|
|
claims.ShouldNotContain(c => c.Type == "DisplayName",
|
|
"Old 'DisplayName' literal must not be emitted after Task 1.5 migration");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Task 1.5 — JWT payload uses canonical claim keys: after login and token issue the JWT
|
|
/// payload segment MUST contain "zb:username" and "zb:displayname" keys (not the old short
|
|
/// "Username"/"DisplayName" strings).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Token_payload_uses_canonical_zb_claim_keys()
|
|
{
|
|
var client = NewClient();
|
|
|
|
var loginResp = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
loginResp.EnsureSuccessStatusCode();
|
|
|
|
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
|
AttachCookies(tokenReq, loginResp);
|
|
var tokenResp = await client.SendAsync(tokenReq, Ct);
|
|
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
var payload = await tokenResp.Content.ReadFromJsonAsync<JsonElement>(Ct);
|
|
var jwt = payload.GetProperty("token").GetString()!;
|
|
|
|
var payloadJson = JwtPayloadJson(jwt);
|
|
|
|
// Canonical "zb:username" key must be present.
|
|
payloadJson.TryGetProperty("zb:username", out var usernameEl).ShouldBeTrue(
|
|
"JWT payload must carry 'zb:username' claim (canonical ZbClaimTypes.Username)");
|
|
usernameEl.GetString().ShouldBe("alice");
|
|
|
|
// Canonical "zb:displayname" key must be present.
|
|
payloadJson.TryGetProperty("zb:displayname", out var displayNameEl).ShouldBeTrue(
|
|
"JWT payload must carry 'zb:displayname' claim (canonical ZbClaimTypes.DisplayName)");
|
|
displayNameEl.GetString().ShouldBe("Alice User");
|
|
|
|
// Old short-name literals must NOT be present.
|
|
payloadJson.TryGetProperty("Username", out _).ShouldBeFalse(
|
|
"JWT payload must not carry old 'Username' key after Task 1.5 migration");
|
|
payloadJson.TryGetProperty("DisplayName", out _).ShouldBeFalse(
|
|
"JWT payload must not carry old 'DisplayName' key after Task 1.5 migration");
|
|
}
|
|
|
|
/// <summary>Tests that logout clears the cookie.</summary>
|
|
[Fact]
|
|
public async Task Logout_clears_the_cookie()
|
|
{
|
|
var client = NewClient();
|
|
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
|
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
|
loginResponse.EnsureSuccessStatusCode();
|
|
|
|
var logoutReq = new HttpRequestMessage(HttpMethod.Post, "/auth/logout");
|
|
logoutReq.Headers.Accept.ParseAdd("application/json");
|
|
AttachCookies(logoutReq, loginResponse);
|
|
var response = await client.SendAsync(logoutReq, Ct);
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
|
|
|
response.Headers.GetValues("Set-Cookie")
|
|
.ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
/// <summary>Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.</summary>
|
|
[Fact]
|
|
public async Task Root_anonymous_browser_GET_redirects_to_login()
|
|
{
|
|
var client = NewClientNoRedirect();
|
|
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
|
req.Headers.Accept.ParseAdd("text/html");
|
|
var resp = await client.SendAsync(req, Ct);
|
|
|
|
resp.StatusCode.ShouldBe(HttpStatusCode.Found);
|
|
resp.Headers.Location.ShouldNotBeNull();
|
|
resp.Headers.Location!.OriginalString.ShouldContain("/login");
|
|
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
|
|
}
|
|
|
|
/// <summary>Anonymous XHR GET of a protected route returns 401 (caller signaled non-browser
|
|
/// via the <c>X-Requested-With</c> header — the ASP.NET cookie handler's IsAjaxRequest
|
|
/// heuristic). The framework still writes a <c>Location</c> header alongside the 401;
|
|
/// AJAX clients ignore it.</summary>
|
|
[Fact]
|
|
public async Task Root_anonymous_xhr_GET_returns_401()
|
|
{
|
|
var client = NewClientNoRedirect();
|
|
var req = new HttpRequestMessage(HttpMethod.Get, "/");
|
|
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
|
var resp = await client.SendAsync(req, Ct);
|
|
|
|
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
private static void AttachCookies(HttpRequestMessage request, HttpResponseMessage prior)
|
|
{
|
|
if (!prior.Headers.TryGetValues("Set-Cookie", out var setCookies)) return;
|
|
var cookiePairs = setCookies
|
|
.Select(c => c.Split(';', 2)[0])
|
|
.ToArray();
|
|
request.Headers.Add("Cookie", string.Join("; ", cookiePairs));
|
|
}
|
|
|
|
private sealed class StubLdapAuthService : ILdapAuthService
|
|
{
|
|
/// <summary>Authenticates a user asynchronously using the stub service.</summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The password to verify.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The authentication result.</returns>
|
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
|
{
|
|
if (username == "ldap-down")
|
|
throw new InvalidOperationException("simulated LDAP outage");
|
|
if (password == "valid-password")
|
|
return Task.FromResult(new LdapAuthResult(
|
|
Success: true,
|
|
DisplayName: "Alice User",
|
|
Username: username,
|
|
Groups: ["ReadOnly"],
|
|
// Roles empty — the real production path returns groups, never roles. Role
|
|
// resolution is the mapper's job (OtOpcUaGroupRoleMapper applies the
|
|
// GroupToRole baseline). This proves roles flow through the mapper, not via
|
|
// pre-population of the auth result.
|
|
Roles: [],
|
|
Error: null));
|
|
return Task.FromResult(new LdapAuthResult(
|
|
Success: false,
|
|
DisplayName: null,
|
|
Username: username,
|
|
Groups: [],
|
|
Roles: [],
|
|
Error: "Invalid username or password"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory stub for the DB-backed group→role mapping service. Tests seed <see cref="Rows"/>
|
|
/// and the login handler merges any system-wide row whose group the user holds. Set
|
|
/// <see cref="Throws"/> to simulate a DB outage and exercise the baseline-roles fallback.
|
|
/// </summary>
|
|
private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService
|
|
{
|
|
public List<LdapGroupRoleMapping> Rows { get; } = [];
|
|
public bool Throws { get; set; }
|
|
|
|
/// <summary>Returns seeded rows whose group matches one of <paramref name="ldapGroups"/>.</summary>
|
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
|
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
|
{
|
|
if (Throws) throw new InvalidOperationException("simulated DB outage");
|
|
var groups = new HashSet<string>(ldapGroups, StringComparer.OrdinalIgnoreCase);
|
|
IReadOnlyList<LdapGroupRoleMapping> matched =
|
|
[.. Rows.Where(r => groups.Contains(r.LdapGroup))];
|
|
return Task.FromResult(matched);
|
|
}
|
|
|
|
/// <summary>Not exercised by these tests.</summary>
|
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <summary>Not exercised by these tests.</summary>
|
|
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
|
|
/// <summary>Not exercised by these tests.</summary>
|
|
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
/// <summary>
|
|
/// DTO for deserialising the /auth/whoami claim list.
|
|
/// Must match the anonymous projection in the whoami endpoint.
|
|
/// </summary>
|
|
private sealed record ClaimDto(string Type, string Value);
|
|
}
|