diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs new file mode 100644 index 0000000..ed9d160 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -0,0 +1,204 @@ +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")); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj index 0d939e8..4a9c75e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj @@ -12,6 +12,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive