using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
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 Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
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 readonly StubLdapGroupRoleMappingService _roleMappings = new();
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// Initializes the test host and server.
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();
// The login handler now resolves the DB role-map service via DI to merge
// DB-backed grants on top of the appsettings baseline. Register the stub so
// the minimal-API handler can be constructed; tests drive its rows.
services.AddSingleton(_roleMappings);
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(e =>
{
e.MapOtOpcUaAuth();
// Protected root used by AuthChallengeTests below — exercises the cookie
// scheme's challenge heuristic without depending on the full Razor host.
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
});
});
})
.Build();
await _host.StartAsync(Ct);
_server = _host.GetTestServer();
}
/// Disposes the test host and server.
public async ValueTask DisposeAsync()
{
await _host.StopAsync(TestContext.Current.CancellationToken);
_host.Dispose();
}
private HttpClient NewClient() => _server.CreateClient();
/// Creates a TestServer-backed HttpClient that does NOT auto-follow redirects.
/// Used by challenge tests so we can assert on the 302 + Location directly.
private HttpClient NewClientNoRedirect() => new(_server.CreateHandler())
{
BaseAddress = _server.BaseAddress,
};
/// Tests that login with valid credentials returns 204 and sets cookie.
[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("ZB.MOM.WW.OtOpcUa.Auth="));
}
/// Tests that login with invalid credentials returns 401.
[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);
}
/// Tests that login when LDAP throws returns 503.
[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);
}
/// Tests that ping anonymous returns 401.
[Fact]
public async Task Ping_anonymous_returns_401()
{
var client = NewClient();
var response = await client.GetAsync("/auth/ping", Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
/// Tests that ping after cookie login returns 200.
[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);
}
/// Tests that token after cookie login returns jwt.
[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);
}
/// A system-wide DB row for a group the user holds grants an extra role on top of
/// the appsettings baseline; the merged role surfaces in the issued JWT's Role claims.
[Fact]
public async Task Login_merges_db_role_grant_into_claims()
{
// StubLdapAuthService returns Groups ["ReadOnly"], baseline Roles ["ConfigViewer"].
// A system-wide row maps "ReadOnly" → FleetAdmin, so the merged set is both.
_roleMappings.Rows.Add(new LdapGroupRoleMapping
{
Id = Guid.NewGuid(),
LdapGroup = "ReadOnly",
Role = AdminRole.FleetAdmin,
IsSystemWide = true,
ClusterId = null,
});
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 tokenResp = await client.SendAsync(tokenReq, Ct);
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await tokenResp.Content.ReadFromJsonAsync(Ct);
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved
roles.ShouldContain("FleetAdmin"); // DB grant merged in
}
/// When the DB role-map lookup throws, sign-in still succeeds with the appsettings
/// baseline roles — a DB hiccup must never block login.
[Fact]
public async Task Login_when_db_role_map_throws_falls_back_to_baseline_roles()
{
_roleMappings.Throws = true;
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
// Login proceeds despite the simulated DB outage.
loginResponse.StatusCode.ShouldBe(HttpStatusCode.NoContent);
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var tokenResp = await client.SendAsync(tokenReq, Ct);
tokenResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var payload = await tokenResp.Content.ReadFromJsonAsync(Ct);
var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!);
roles.ShouldContain("ConfigViewer"); // baseline still present
}
/// Extracts the "Role" claim values from a JWT's payload segment.
private static IReadOnlyList JwtRoleClaims(string jwt)
{
var payloadSegment = jwt.Split('.')[1];
var padded = payloadSegment.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var json = JsonDocument.Parse(Convert.FromBase64String(padded));
if (!json.RootElement.TryGetProperty("Role", out var roleProp)) return [];
return roleProp.ValueKind == JsonValueKind.Array
? [.. roleProp.EnumerateArray().Select(e => e.GetString()!)]
: [roleProp.GetString()!];
}
/// Tests that logout clears the cookie.
[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");
logoutReq.Headers.Accept.ParseAdd("application/json");
AttachCookies(logoutReq, loginResponse);
var response = await client.SendAsync(logoutReq, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
response.Headers.GetValues("Set-Cookie")
.ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth=") && c.Contains("expires=", StringComparison.OrdinalIgnoreCase));
}
/// Anonymous browser GET of a protected route redirects to /login with a ReturnUrl.
[Fact]
public async Task Root_anonymous_browser_GET_redirects_to_login()
{
var client = NewClientNoRedirect();
var req = new HttpRequestMessage(HttpMethod.Get, "/");
req.Headers.Accept.ParseAdd("text/html");
var resp = await client.SendAsync(req, Ct);
resp.StatusCode.ShouldBe(HttpStatusCode.Found);
resp.Headers.Location.ShouldNotBeNull();
resp.Headers.Location!.OriginalString.ShouldContain("/login");
resp.Headers.Location.OriginalString.ShouldContain("ReturnUrl");
}
/// Anonymous XHR GET of a protected route returns 401 (caller signaled non-browser
/// via the X-Requested-With header — the ASP.NET cookie handler's IsAjaxRequest
/// heuristic). The framework still writes a Location header alongside the 401;
/// AJAX clients ignore it.
[Fact]
public async Task Root_anonymous_xhr_GET_returns_401()
{
var client = NewClientNoRedirect();
var req = new HttpRequestMessage(HttpMethod.Get, "/");
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
var resp = await client.SendAsync(req, Ct);
resp.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
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
{
/// Authenticates a user asynchronously using the stub service.
/// The username to authenticate.
/// The password to verify.
/// The cancellation token.
/// The authentication result.
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"));
}
}
///
/// In-memory stub for the DB-backed group→role mapping service. Tests seed
/// and the login handler merges any system-wide row whose group the user holds. Set
/// to simulate a DB outage and exercise the baseline-roles fallback.
///
private sealed class StubLdapGroupRoleMappingService : ILdapGroupRoleMappingService
{
public List Rows { get; } = [];
public bool Throws { get; set; }
/// Returns seeded rows whose group matches one of .
public Task> GetByGroupsAsync(
IEnumerable ldapGroups, CancellationToken cancellationToken)
{
if (Throws) throw new InvalidOperationException("simulated DB outage");
var groups = new HashSet(ldapGroups, StringComparer.OrdinalIgnoreCase);
IReadOnlyList matched =
[.. Rows.Where(r => groups.Contains(r.LdapGroup))];
return Task.FromResult(matched);
}
/// Not exercised by these tests.
public Task> ListAllAsync(CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// Not exercised by these tests.
public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) =>
throw new NotSupportedException();
/// Not exercised by these tests.
public Task DeleteAsync(Guid id, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
}