using System.Net; using System.Net.Http.Json; using Microsoft.AspNetCore.Authentication; 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 Microsoft.Extensions.Options; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Services; using ZB.MOM.WW.OtOpcUa.Security.Auth; 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; /// /// R03:2.4 — JWT is opt-in by signing key. Unconfigured: /auth/token is not mapped (it used to /// answer HTTP 500 from the service constructor) and no bearer scheme exists. Configured badly: the /// host refuses to START rather than failing on the first token request. /// public sealed class JwtConfigurationGateTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; [Fact] public async Task Without_a_signing_key_auth_token_is_not_mapped_and_bearer_is_ignored() { using var host = await StartHostAsync(signingKey: null); var server = host.GetTestServer(); var client = server.CreateClient(); // The login still works — the cookie path is untouched. var login = await client.PostAsJsonAsync("/auth/login", new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct); login.StatusCode.ShouldBe(HttpStatusCode.NoContent); // /auth/token is simply absent (404), not a 500. var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token"); foreach (var c in login.Headers.GetValues("Set-Cookie")) tokenReq.Headers.Add("Cookie", c.Split(';')[0]); (await client.SendAsync(tokenReq, Ct)).StatusCode.ShouldBe(HttpStatusCode.NotFound); // No bearer scheme is registered, so a bearer header is ignored and the cookie session still wins. var schemes = host.Services.GetRequiredService(); (await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer)).ShouldBeNull(); var withBogusBearer = new HttpRequestMessage(HttpMethod.Get, "/"); foreach (var c in login.Headers.GetValues("Set-Cookie")) withBogusBearer.Headers.Add("Cookie", c.Split(';')[0]); withBogusBearer.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "garbage"); (await client.SendAsync(withBogusBearer, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK); await host.StopAsync(Ct); } /// Positive control for the gate: with a key, the bearer scheme IS registered. [Fact] public async Task With_a_signing_key_the_bearer_scheme_is_registered() { using var host = await StartHostAsync(signingKey: "test-signing-key-with-at-least-32-bytes-of-utf8-content"); var schemes = host.Services.GetRequiredService(); var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer); bearer.ShouldNotBeNull(); bearer.HandlerType.ShouldBe(typeof(JwtBearerAuthenticationHandler)); await host.StopAsync(Ct); } /// A configured-but-short key fails at host start (ValidateOnStart), not on the first /auth/token. [Fact] public async Task A_short_signing_key_fails_host_start() { var ex = await Should.ThrowAsync(async () => { using var host = await StartHostAsync(signingKey: "too-short"); }); ex.Message.ShouldContain("SigningKey"); } private static async Task StartHostAsync(string? signingKey) { var dbName = $"jwt-gate-{Guid.NewGuid():N}"; var settings = new Dictionary(); if (signingKey is not null) settings["Security:Jwt:SigningKey"] = signingKey; var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); var host = new HostBuilder() .ConfigureWebHost(web => { web.UseTestServer(); web.ConfigureServices(services => { services.AddDbContextFactory(opt => opt.UseInMemoryDatabase(dbName)); services.AddDbContext(opt => opt.UseInMemoryDatabase(dbName)); services.AddRouting(); services.AddOtOpcUaAuth(configuration); services.AddSingleton(); services.AddSingleton(); }); web.Configure(app => { app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(e => { e.MapOtOpcUaAuth(); e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization(); }); }); }) .Build(); await host.StartAsync(Ct); return host; } private sealed class AlwaysAliceLdap : ILdapAuthService { public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) => Task.FromResult(new LdapAuthResult( Success: password == "valid-password", DisplayName: "Alice", Username: username, Groups: [], Roles: [], Error: null)); } }