diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
new file mode 100644
index 0000000..ed9d160
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs
@@ -0,0 +1,204 @@
+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;
+
+///
+/// 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 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(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();
+ });
+ 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(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 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"));
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj
index 0d939e8..4a9c75e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj
@@ -12,6 +12,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive