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.Jwt; using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Security.Tests; /// /// End-to-end auth contract test: exercises AddOtOpcUaAuth + MapOtOpcUaAuth /// through an in-memory TestServer. Scope is the auth surface — not the fused /// OtOpcUa.Host bootstrap (that would entail Akka cluster + role gating, which /// belongs in the multi-node Task 58 harness). Stub /// drives the auth outcomes; uses EF in-memory so /// DataProtection can persist keys. /// 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; /// Initializes the test host and server. public async ValueTask InitializeAsync() { var dbName = $"auth-int-tests-{Guid.NewGuid():N}"; _host = new HostBuilder() .ConfigureWebHost(web => { web.UseTestServer(); web.ConfigureServices(services => { services.AddDbContextFactory(opt => opt.UseInMemoryDatabase(dbName)); services.AddDbContext(opt => opt.UseInMemoryDatabase(dbName)); services.AddRouting(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["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(); // 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(_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(); } /// Disposes the test host and server. public async ValueTask DisposeAsync() { await _host.StopAsync(TestContext.Current.CancellationToken); _host.Dispose(); } private HttpClient NewClient() => _server.CreateClient(); /// Creates a TestServer-backed HttpClient that does NOT auto-follow redirects. /// Used by challenge tests so we can assert on the 302 + Location directly. private HttpClient NewClientNoRedirect() => new(_server.CreateHandler()) { BaseAddress = _server.BaseAddress, }; /// Tests that login with valid credentials returns 204 and sets cookie. [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=")); } /// Tests that login with invalid credentials returns 401. [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); } /// Tests that login when LDAP throws returns 503. [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); } /// Tests that ping anonymous returns 401. [Fact] public async Task Ping_anonymous_returns_401() { var client = NewClient(); var response = await client.GetAsync("/auth/ping", Ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } /// Tests that ping after cookie login returns 200. [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); } /// Tests that token after cookie login returns jwt. [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(Ct); var token = payload.GetProperty("token").GetString(); token.ShouldNotBeNullOrEmpty(); token!.Split('.').Length.ShouldBe(3); } /// 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. [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(Ct); var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!); roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved roles.ShouldContain("FleetAdmin"); // DB grant merged in } /// 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. [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(Ct); var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!); roles.ShouldBeEmpty(); } /// Parses the payload segment of a JWT and returns it as a . 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; } /// Extracts the "Role" claim values from a JWT's payload segment. private static IReadOnlyList 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()!]; } /// /// Task 1.5 — canonical claims contract: after a successful cookie login the authenticated /// principal MUST carry the canonical ZbClaimTypes vocabulary: /// /// (= ClaimTypes.Name) so Identity.Name resolves. /// (= "zb:username") — login username. /// (= "zb:displayname") — human-friendly name. /// (= ClaimTypes.Role) — at least one role claim. /// /// 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). /// [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(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"); } /// /// 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), AND the role claim(s) MUST be carried under the key /// (currently the short "Role" key — intentionally /// NOT the long ClaimTypes.Role URI, because OtOpcUa is JWT-issued-only; see /// docs for the rationale and the caveat that /// applies if a JwtBearer scheme is ever added). /// [Fact] public async Task Token_payload_uses_canonical_zb_claim_keys() { // Arrange — the appsettings baseline maps group "ReadOnly" → role "ConfigViewer", so alice // (whose groups are ["ReadOnly"]) will carry at least one role in the issued JWT. // No extra DB rows needed — the appsettings GroupToRole entry is always active. 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(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"); // Role claim(s) must be carried under JwtTokenService.RoleClaimType (= "Role"). // This pins the role-key contract: any future rename of RoleClaimType will be caught here. // The appsettings "ReadOnly" → "ConfigViewer" mapping guarantees alice has ≥1 role. payloadJson.TryGetProperty(JwtTokenService.RoleClaimType, out var roleEl).ShouldBeTrue( $"JWT payload must carry at least one role under JwtTokenService.RoleClaimType " + $"(\"{JwtTokenService.RoleClaimType}\")"); // The role value may be a string (single) or array (multiple); either way it must be non-empty. if (roleEl.ValueKind == JsonValueKind.Array) roleEl.EnumerateArray().Select(e => e.GetString()).ShouldNotBeEmpty( "JWT role array must contain at least one role value"); else roleEl.GetString().ShouldNotBeNullOrEmpty("JWT role value must not be empty"); // 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"); } /// Tests that logout clears the cookie. [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)); } /// Anonymous browser GET of a protected route redirects to /login with a ReturnUrl. [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"); } /// Anonymous XHR GET of a protected route returns 401 (caller signaled non-browser /// via the X-Requested-With header — the ASP.NET cookie handler's IsAjaxRequest /// heuristic). The framework still writes a Location header alongside the 401; /// AJAX clients ignore it. [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 { /// Authenticates a user asynchronously using the stub service. /// The username to authenticate. /// The password to verify. /// The cancellation token. /// The authentication result. public Task 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")); } } /// /// In-memory stub for the DB-backed group→role mapping service. Tests seed /// and the login handler merges any system-wide row whose group the user holds. Set /// to simulate a DB outage and exercise the baseline-roles fallback. /// private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService { public List Rows { get; } = []; public bool Throws { get; set; } /// Returns seeded rows whose group matches one of . public Task> GetByGroupsAsync( IEnumerable ldapGroups, CancellationToken cancellationToken) { if (Throws) throw new InvalidOperationException("simulated DB outage"); var groups = new HashSet(ldapGroups, StringComparer.OrdinalIgnoreCase); IReadOnlyList matched = [.. Rows.Where(r => groups.Contains(r.LdapGroup))]; return Task.FromResult(matched); } /// Not exercised by these tests. public Task> ListAllAsync(CancellationToken cancellationToken) => throw new NotSupportedException(); /// Not exercised by these tests. public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) => throw new NotSupportedException(); /// Not exercised by these tests. public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => throw new NotSupportedException(); } /// /// DTO for deserialising the /auth/whoami claim list. /// Must match the anonymous projection in the whoami endpoint. /// private sealed record ClaimDto(string Type, string Value); }