using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; 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.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 static CancellationToken Ct => TestContext.Current.CancellationToken; 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(); }); web.Configure(app => { app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(e => e.MapOtOpcUaAuth()); }); }) .Build(); await _host.StartAsync(Ct); _server = _host.GetTestServer(); } public async ValueTask DisposeAsync() { await _host.StopAsync(TestContext.Current.CancellationToken); _host.Dispose(); } private HttpClient NewClient() => _server.CreateClient(); [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("OtOpcUa.Auth=")); } [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); } [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); } [Fact] public async Task Ping_anonymous_returns_401() { var client = NewClient(); var response = await client.GetAsync("/auth/ping", Ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } [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); } [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); } [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"); AttachCookies(logoutReq, loginResponse); var response = await client.SendAsync(logoutReq, Ct); response.StatusCode.ShouldBe(HttpStatusCode.NoContent); response.Headers.GetValues("Set-Cookie") .ShouldContain(c => c.StartsWith("OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase)); } 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 { 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")); } } }