using System.Net; using System.Net.Http.Json; using System.Text.Json; 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.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; /// /// 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", }).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(); }); }); }) .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"], baseline Roles ["ConfigViewer"]. // A system-wide 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 } /// When the DB role-map lookup throws, sign-in still succeeds with the appsettings /// baseline roles — a DB hiccup must never block login. [Fact] public async Task Login_when_db_role_map_throws_falls_back_to_baseline_roles() { _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. 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); var payload = await tokenResp.Content.ReadFromJsonAsync(Ct); var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!); roles.ShouldContain("ConfigViewer"); // baseline still present } /// 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()!]; } /// 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: ["ConfigViewer"], 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(); } }