Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
Joseph Doherty 463512d1d8 test(security): AuthEndpoints integration tests via TestServer (F1)
7 tests exercise AddOtOpcUaAuth + MapOtOpcUaAuth end-to-end against an
in-memory ConfigDb + stub ILdapAuthService. Covers /auth/login (204/401/503),
/auth/ping (401/200), /auth/token (200+JWT shape), /auth/logout (204+clear-cookie).

Scope is the auth contract — not the fused Host bootstrap (cluster + role
gating belongs in the Task 58 multi-node harness). HostBuilder + TestServer
is used directly instead of WebApplicationFactory<Program> because the
test project has no Program entry point and Host needs Akka cluster up.
2026-05-26 06:15:07 -04:00

205 lines
7.7 KiB
C#

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;
/// <summary>
/// End-to-end auth contract test: exercises <c>AddOtOpcUaAuth + MapOtOpcUaAuth</c>
/// through an in-memory <c>TestServer</c>. Scope is the auth surface — not the fused
/// <c>OtOpcUa.Host</c> bootstrap (that would entail Akka cluster + role gating, which
/// belongs in the multi-node Task 58 harness). Stub <see cref="ILdapAuthService"/>
/// drives the auth outcomes; <see cref="OtOpcUaConfigDbContext"/> uses EF in-memory so
/// DataProtection can persist keys.
/// </summary>
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<OtOpcUaConfigDbContext>(opt =>
opt.UseInMemoryDatabase(dbName));
services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
opt.UseInMemoryDatabase(dbName));
services.AddRouting();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<ILdapAuthService, StubLdapAuthService>();
});
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<JsonElement>(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<LdapAuthResult> 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"));
}
}
}