74b9218a92
Single Cookie auth scheme; framework default challenge restores 302 → /login for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the options class was bound but ignored). Cookie name moves to ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out.
219 lines
8.7 KiB
C#
219 lines
8.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;
|
|
|
|
/// <summary>Initializes the test host and server.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>Disposes the test host and server.</summary>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _host.StopAsync(TestContext.Current.CancellationToken);
|
|
_host.Dispose();
|
|
}
|
|
|
|
private HttpClient NewClient() => _server.CreateClient();
|
|
|
|
/// <summary>Tests that login with valid credentials returns 204 and sets cookie.</summary>
|
|
[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="));
|
|
}
|
|
|
|
/// <summary>Tests that login with invalid credentials returns 401.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Tests that login when LDAP throws returns 503.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Tests that ping anonymous returns 401.</summary>
|
|
[Fact]
|
|
public async Task Ping_anonymous_returns_401()
|
|
{
|
|
var client = NewClient();
|
|
var response = await client.GetAsync("/auth/ping", Ct);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
/// <summary>Tests that ping after cookie login returns 200.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Tests that token after cookie login returns jwt.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Tests that logout clears the cookie.</summary>
|
|
[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
|
|
{
|
|
/// <summary>Authenticates a user asynchronously using the stub service.</summary>
|
|
/// <param name="username">The username to authenticate.</param>
|
|
/// <param name="password">The password to verify.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <returns>The authentication result.</returns>
|
|
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"));
|
|
}
|
|
}
|
|
}
|