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));
}
}