chore(cleanup): delete OtOpcUa.Server, OtOpcUa.Admin, and obsolete v1 tests
Task 56: removes the legacy in-process Server + Admin Web project + their test projects (Server.Tests, Admin.Tests, Admin.E2ETests). The fused OtOpcUa.Host binary built across Phases 1-9 is now the sole production entry point. What happened to the 47 legacy Admin Blazor pages: per follow-up F15, the v1 architecture's draft/publish UX is replaced by v2's live-edit + snapshot- deploy model, so a 1:1 migration is not meaningful. The mechanical move via git mv preserves the history; service classes + page bodies that referenced removed v1 types (ConfigGeneration, RedundancyRole, GenerationId) were deleted. AdminUI now ships a minimal Home page + the v2 Deployments page. Per-page rebuild against the v2 surface is tracked as F15. The v2 Deployments page (Task 52) is the only first-party UI shipping in this PR. Task 57: solution build green; 84+ tests green across active v2 + legacy driver test projects.
This commit is contained in:
@@ -1,182 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Stands up the Admin Blazor Server host on a free TCP port with the live SQL Server
|
||||
/// context swapped for an EF Core InMemory DbContext + the LDAP cookie auth swapped for
|
||||
/// <see cref="TestAuthHandler"/>. Playwright connects to <see cref="BaseUrl"/>.
|
||||
/// InMemory is sufficient because UnsService's drag-drop path exercises EF operations,
|
||||
/// not raw SQL.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We deliberately build a <see cref="WebApplication"/> directly rather than going through
|
||||
/// <c>WebApplicationFactory<Program></c> — the factory's TestServer transport doesn't
|
||||
/// coexist cleanly with Kestrel-on-a-real-port, and Playwright needs a real loopback HTTP
|
||||
/// endpoint to hit. This mirrors the Program.cs entry-points for everything else.
|
||||
/// </remarks>
|
||||
public sealed class AdminWebAppFactory : IAsyncDisposable
|
||||
{
|
||||
private WebApplication? _app;
|
||||
|
||||
public string BaseUrl { get; private set; } = "";
|
||||
public long SeededGenerationId { get; private set; }
|
||||
public string SeededClusterId { get; } = "e2e-cluster";
|
||||
|
||||
/// <summary>
|
||||
/// Root service provider of the running host. Tests use this to create scopes that
|
||||
/// share the InMemory DB with the Blazor-rendered page — e.g. to assert post-commit
|
||||
/// state, or to simulate a concurrent peer edit that bumps the DraftRevisionToken
|
||||
/// between preview-open and Confirm-click.
|
||||
/// </summary>
|
||||
public IServiceProvider Services => _app?.Services
|
||||
?? throw new InvalidOperationException("AdminWebAppFactory: StartAsync has not been called");
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var port = GetFreeTcpPort();
|
||||
BaseUrl = $"http://127.0.0.1:{port}";
|
||||
|
||||
// Point the content root at the Admin project's build output so the Admin
|
||||
// assembly + its sibling staticwebassets manifest are discoverable. The manifest
|
||||
// maps /_framework/* to the framework NuGet cache + /app.css to the Admin source
|
||||
// wwwroot; StaticWebAssetsLoader.UseStaticWebAssets reads it and wires a composite
|
||||
// file provider automatically.
|
||||
var adminAssemblyDir = System.IO.Path.GetDirectoryName(
|
||||
typeof(Admin.Components.App).Assembly.Location)!;
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
ContentRootPath = adminAssemblyDir,
|
||||
ApplicationName = typeof(Admin.Components.App).Assembly.GetName().Name,
|
||||
});
|
||||
builder.WebHost.UseUrls(BaseUrl);
|
||||
// UseStaticWebAssets reads {ApplicationName}.staticwebassets.runtime.json (or the
|
||||
// development variant via the ASPNETCORE_HOSTINGSTARTUPASSEMBLIES convention) and
|
||||
// composes a PhysicalFileProvider per declared ContentRoot. This is what
|
||||
// `dotnet run` does automatically via the MSBuild targets — we replicate it
|
||||
// explicitly for the test-owned pipeline.
|
||||
builder.WebHost.UseStaticWebAssets();
|
||||
// E2E host runs in Development so unhandled exceptions during Blazor render surface
|
||||
// as visible 500s with stacks the test can capture — prod-style generic errors make
|
||||
// diagnosis of circuit / DI misconfig effectively impossible.
|
||||
builder.Environment.EnvironmentName = Microsoft.Extensions.Hosting.Environments.Development;
|
||||
|
||||
// --- Mirror the Admin composition in Program.cs, but with the InMemory DB + test
|
||||
// auth swaps instead of SQL Server + LDAP cookie auth.
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddAntiforgery();
|
||||
|
||||
builder.Services.AddAuthentication(TestAuthHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(Admin.Services.AdminRoles.ConfigEditor, Admin.Services.AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(Admin.Services.AdminRoles.FleetAdmin));
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
// One InMemory database name per fixture — the lambda below runs on every DbContext
|
||||
// construction, so capturing a stable string (not calling Guid.NewGuid() inline) is
|
||||
// critical: every scope (seed, Blazor circuit, test assertions) must share the same
|
||||
// backing store or rows written in one scope disappear in the next.
|
||||
var dbName = $"e2e-{Guid.NewGuid():N}";
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseInMemoryDatabase(dbName));
|
||||
|
||||
builder.Services.AddScoped<Admin.Services.ClusterService>();
|
||||
builder.Services.AddScoped<Admin.Services.GenerationService>();
|
||||
builder.Services.AddScoped<Admin.Services.UnsService>();
|
||||
builder.Services.AddScoped<Admin.Services.EquipmentService>();
|
||||
builder.Services.AddScoped<Admin.Services.NamespaceService>();
|
||||
builder.Services.AddScoped<Admin.Services.DriverInstanceService>();
|
||||
builder.Services.AddScoped<Admin.Services.DraftValidationService>();
|
||||
|
||||
// ClusterDetail.razor injects AdminHubConnectionFactory to drive the live-banner hub
|
||||
// connection; the factory depends on HubTokenService, which in turn needs Data Protection.
|
||||
// Without these the InteractiveServer circuit fails to instantiate the component and the
|
||||
// page never advances past the "Loading…" placeholder — Playwright then times out
|
||||
// waiting for any tab nav-link to appear. Mirrors Program.cs:35-36.
|
||||
builder.Services.AddDataProtection();
|
||||
builder.Services.AddSingleton<HubTokenService>();
|
||||
builder.Services.AddScoped<Admin.Services.AdminHubConnectionFactory>();
|
||||
|
||||
_app = builder.Build();
|
||||
_app.UseStaticFiles();
|
||||
_app.UseRouting();
|
||||
_app.UseAuthentication();
|
||||
_app.UseAuthorization();
|
||||
_app.UseAntiforgery();
|
||||
_app.MapRazorComponents<Admin.Components.App>().AddInteractiveServerRenderMode();
|
||||
// The ClusterDetail + other pages connect SignalR hubs at render time — the
|
||||
// endpoints must exist or the Blazor circuit surfaces a 500 on first interactive
|
||||
// step. No background pollers (FleetStatusPoller etc.) are registered so the hubs
|
||||
// stay quiet until something pushes through IHubContext, which the E2E tests don't.
|
||||
_app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||
_app.MapHub<AlertHub>("/hubs/alerts");
|
||||
|
||||
// Seed the draft BEFORE starting the host so Playwright sees a ready page on first nav.
|
||||
using (var scope = _app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
SeededGenerationId = Seed(db, SeededClusterId);
|
||||
}
|
||||
|
||||
await _app.StartAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static long Seed(OtOpcUaConfigDbContext db, string clusterId)
|
||||
{
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = clusterId, Name = "e2e", Enterprise = "zb", Site = "lab",
|
||||
RedundancyMode = RedundancyMode.None, NodeCount = 1, CreatedBy = "e2e",
|
||||
};
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = clusterId, Status = GenerationStatus.Draft, CreatedBy = "e2e",
|
||||
};
|
||||
|
||||
db.ServerClusters.Add(cluster);
|
||||
db.ConfigGenerations.Add(gen);
|
||||
db.SaveChanges();
|
||||
|
||||
db.UnsAreas.AddRange(
|
||||
new UnsArea { UnsAreaId = "area-a", ClusterId = clusterId, Name = "warsaw", GenerationId = gen.GenerationId },
|
||||
new UnsArea { UnsAreaId = "area-b", ClusterId = clusterId, Name = "berlin", GenerationId = gen.GenerationId });
|
||||
db.UnsLines.Add(new UnsLine
|
||||
{
|
||||
UnsLineId = "line-a1", UnsAreaId = "area-a", Name = "oven-line", GenerationId = gen.GenerationId,
|
||||
});
|
||||
db.SaveChanges();
|
||||
return gen.GenerationId;
|
||||
}
|
||||
|
||||
private static int GetFreeTcpPort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using Microsoft.Playwright;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// One Playwright runtime + Chromium browser for the whole E2E suite. Tests
|
||||
/// open a fresh <see cref="IBrowserContext"/> per fixture so cookies + localStorage
|
||||
/// stay isolated. Browser install is a one-time step:
|
||||
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
|
||||
/// When the browser binary isn't present the suite reports a <see cref="PlaywrightBrowserMissingException"/>
|
||||
/// so CI can distinguish missing-browser from real test failure.
|
||||
/// </summary>
|
||||
public sealed class PlaywrightFixture : IAsyncLifetime
|
||||
{
|
||||
public IPlaywright Playwright { get; private set; } = null!;
|
||||
public IBrowser Browser { get; private set; } = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
|
||||
try
|
||||
{
|
||||
Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
|
||||
}
|
||||
catch (PlaywrightException ex) when (ex.Message.Contains("Executable doesn't exist"))
|
||||
{
|
||||
throw new PlaywrightBrowserMissingException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Browser is not null) await Browser.CloseAsync();
|
||||
Playwright?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="PlaywrightFixture"/> when Chromium isn't installed. Tests
|
||||
/// catching this mark themselves as "skipped" rather than "failed", so CI without
|
||||
/// the install step stays green.
|
||||
/// </summary>
|
||||
public sealed class PlaywrightBrowserMissingException(string message) : Exception(message);
|
||||
@@ -1,34 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Stamps every request with a FleetAdmin principal so E2E tests can hit
|
||||
/// authenticated Razor pages without the LDAP login flow. Registered as the
|
||||
/// default authentication scheme by <see cref="AdminWebAppFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class TestAuthHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "Test";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, "e2e-test-user"),
|
||||
new Claim(ClaimTypes.Role, AdminRoles.FleetAdmin),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Playwright;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.E2ETests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.4 UnsTab drag-drop E2E. Task #199 landed the scaffolding; task #242 (this file)
|
||||
/// drives the Blazor Server interactive circuit through a real drag-drop → confirm-modal
|
||||
/// → apply flow and a 409 concurrent-edit flow, both via Chromium.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Prerequisite.</b> Chromium must be installed locally:
|
||||
/// <c>pwsh tests/ZB.MOM.WW.OtOpcUa.Admin.E2ETests/bin/Debug/net10.0/playwright.ps1 install chromium</c>.
|
||||
/// When the binary is missing the tests <see cref="Assert.Skip"/> rather than fail hard,
|
||||
/// so CI pipelines that don't run the install step still report green.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Harness notes.</b> <see cref="AdminWebAppFactory"/> points the content root at
|
||||
/// the Admin assembly directory + sets <c>ApplicationName</c> + calls
|
||||
/// <c>UseStaticWebAssets</c> so <c>/_framework/blazor.web.js</c> + <c>/app.css</c>
|
||||
/// resolve from the Admin's <c>staticwebassets.development.json</c> manifest (which
|
||||
/// stitches together Admin <c>wwwroot</c> + the framework NuGet cache). Hubs
|
||||
/// <c>/hubs/fleet</c> + <c>/hubs/alerts</c> are mapped so <c>ClusterDetail</c>'s
|
||||
/// <c>HubConnection</c> negotiation doesn't 500 at first render. The InMemory
|
||||
/// database name is captured as a stable string per fixture instance so the seed
|
||||
/// scope + Blazor circuit scope + test-assertion scope all share one backing store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class UnsTabDragDropE2ETests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Admin_host_serves_HTTP_via_Playwright_scaffolding()
|
||||
{
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
var fixture = await TryInitPlaywrightAsync();
|
||||
if (fixture is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
var page = await ctx.NewPageAsync();
|
||||
|
||||
var response = await page.GotoAsync(app.BaseUrl);
|
||||
|
||||
response.ShouldNotBeNull();
|
||||
response!.Status.ShouldBeLessThan(500,
|
||||
$"Admin host returned HTTP {response.Status} at root — scaffolding broken");
|
||||
|
||||
var body = await page.Locator("body").InnerHTMLAsync();
|
||||
body.Length.ShouldBeGreaterThan(0, "empty body = routing pipeline didn't hit Razor");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dragging_line_onto_new_area_shows_preview_modal_then_confirms_the_move()
|
||||
{
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
var fixture = await TryInitPlaywrightAsync();
|
||||
if (fixture is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
var page = await ctx.NewPageAsync();
|
||||
|
||||
await OpenUnsTabAsync(page, app);
|
||||
|
||||
// The seed wires line 'oven-line' to area 'warsaw' (area-a); dragging it onto
|
||||
// 'berlin' (area-b) should surface the preview modal. Playwright's DragToAsync
|
||||
// dispatches native dragstart / dragover / drop events that the razor's
|
||||
// @ondragstart / @ondragover / @ondrop handlers pick up.
|
||||
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||
await lineRow.DragToAsync(berlinRow);
|
||||
|
||||
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||
|
||||
var modalBody = await page.Locator(".modal-body").InnerTextAsync();
|
||||
modalBody.ShouldContain("Equipment re-homed",
|
||||
customMessage: "preview modal should render UnsImpactAnalyzer summary");
|
||||
|
||||
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||
.ClickAsync();
|
||||
|
||||
// Modal dismisses after the MoveLineAsync round-trip + ReloadAsync.
|
||||
await modalTitle.WaitForAsync(new() { State = WaitForSelectorState.Detached, Timeout = 10_000 });
|
||||
|
||||
// Persisted state: the line row now shows area-b as its Area column value.
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var line = await db.UnsLines.AsNoTracking()
|
||||
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||
line.UnsAreaId.ShouldBe("area-b",
|
||||
"drag-drop should have moved the line to the berlin area via UnsService.MoveLineAsync");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_shown_then_peer_edit_applied_surfaces_409_conflict_modal()
|
||||
{
|
||||
await using var app = new AdminWebAppFactory();
|
||||
await app.StartAsync();
|
||||
|
||||
var fixture = await TryInitPlaywrightAsync();
|
||||
if (fixture is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = await fixture.Browser.NewContextAsync();
|
||||
var page = await ctx.NewPageAsync();
|
||||
|
||||
await OpenUnsTabAsync(page, app);
|
||||
|
||||
// Open the preview first (same drag as the happy-path test). The preview captures
|
||||
// a RevisionToken under the current draft state.
|
||||
var lineRow = page.Locator("table >> tr", new() { HasTextString = "oven-line" });
|
||||
var berlinRow = page.Locator("table >> tr", new() { HasTextString = "berlin" });
|
||||
await lineRow.DragToAsync(berlinRow);
|
||||
|
||||
var modalTitle = page.Locator(".modal-title", new() { HasTextString = "Confirm UNS move" });
|
||||
await modalTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// Simulate a concurrent operator committing their own edit between the preview
|
||||
// open + our Confirm click — bumps the DraftRevisionToken so our stale token hits
|
||||
// DraftRevisionConflictException in UnsService.MoveLineAsync.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var uns = scope.ServiceProvider.GetRequiredService<Admin.Services.UnsService>();
|
||||
await uns.AddAreaAsync(app.SeededGenerationId, app.SeededClusterId,
|
||||
"madrid", notes: null, CancellationToken.None);
|
||||
}
|
||||
|
||||
await page.Locator("button.btn.btn-primary", new() { HasTextString = "Confirm move" })
|
||||
.ClickAsync();
|
||||
|
||||
var conflictTitle = page.Locator(".modal-title",
|
||||
new() { HasTextString = "Draft changed" });
|
||||
await conflictTitle.WaitForAsync(new() { Timeout = 10_000 });
|
||||
|
||||
// Persisted state: line still points at the original area-a — the conflict short-
|
||||
// circuited the move.
|
||||
using var verifyScope = app.Services.CreateScope();
|
||||
var db = verifyScope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var line = await db.UnsLines.AsNoTracking()
|
||||
.FirstAsync(l => l.UnsLineId == "line-a1" && l.GenerationId == app.SeededGenerationId);
|
||||
line.UnsAreaId.ShouldBe("area-a",
|
||||
"conflict path must not overwrite the peer operator's draft state");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await fixture.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<PlaywrightFixture?> TryInitPlaywrightAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fixture = new PlaywrightFixture();
|
||||
await fixture.InitializeAsync();
|
||||
return fixture;
|
||||
}
|
||||
catch (PlaywrightBrowserMissingException)
|
||||
{
|
||||
Assert.Skip("Chromium not installed. Run playwright.ps1 install chromium.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the seeded cluster and switches to the UNS Structure tab, waiting for
|
||||
/// the Blazor Server interactive circuit to render the draggable line table. Returns
|
||||
/// once the drop-target cells ("drop here") are visible — that's the signal the
|
||||
/// circuit is live and @ondragstart handlers are wired.
|
||||
/// </summary>
|
||||
private static async Task OpenUnsTabAsync(IPage page, AdminWebAppFactory app)
|
||||
{
|
||||
await page.GotoAsync($"{app.BaseUrl}/clusters/{app.SeededClusterId}",
|
||||
new() { WaitUntil = WaitUntilState.NetworkIdle, Timeout = 20_000 });
|
||||
|
||||
var unsTab = page.Locator("button.nav-link", new() { HasTextString = "UNS Structure" });
|
||||
await unsTab.WaitForAsync(new() { Timeout = 15_000 });
|
||||
await unsTab.ClickAsync();
|
||||
|
||||
// "drop here" is the per-area hint cell — only rendered inside <UnsTab> so its
|
||||
// visibility confirms both the tab switch and the circuit's interactive render.
|
||||
await page.Locator("td", new() { HasTextString = "drop here" })
|
||||
.First.WaitForAsync(new() { Timeout = 15_000 });
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin.E2ETests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||
<PackageReference Include="Microsoft.Playwright"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,231 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end HTTP-pipeline tests for the Admin authorization layer — Admin-009.
|
||||
///
|
||||
/// Covers the four cases identified in the finding:
|
||||
/// (a) anonymous access to every protected route is rejected (already in
|
||||
/// <see cref="PageAuthorizationTests"/>; supplemented here with the mutating
|
||||
/// POST surface).
|
||||
/// (b) anonymous hub negotiate is rejected (already in
|
||||
/// <see cref="AuthEndpointsTests"/>; complemented here).
|
||||
/// (c) a signed-in FleetAdmin can reach pages gated by the fallback policy and
|
||||
/// <c>CanPublish</c> pages.
|
||||
/// (d) a <c>ConfigViewer</c> (no FleetAdmin role) is denied <c>CanPublish</c>-gated
|
||||
/// pages while still being allowed through the fallback authenticated-user gate.
|
||||
///
|
||||
/// The test host uses a custom <see cref="RoleInjectingHandler"/> authentication scheme
|
||||
/// so tests can assign any role set without going through LDAP. The <see cref="FleetStatusPoller"/>
|
||||
/// background service is stripped out so the host starts clean without DB access.
|
||||
/// </summary>
|
||||
public sealed class AdminAuthPipelineTests : IClassFixture<AdminAuthPipelineTests.RoleInjectingAppFactory>
|
||||
{
|
||||
private readonly RoleInjectingAppFactory _factory;
|
||||
|
||||
public AdminAuthPipelineTests(RoleInjectingAppFactory factory) => _factory = factory;
|
||||
|
||||
// ── (c) FleetAdmin is NOT rejected by the auth gate ──────────────────────────
|
||||
//
|
||||
// These tests verify that a FleetAdmin principal is not refused at the
|
||||
// authorization boundary. They do NOT assert that the page renders successfully
|
||||
// (the test host has no DB, so pages that hit the DB will 500 — that is an
|
||||
// application error, not an auth error). The assertions are:
|
||||
// • Not 401 Unauthorized (auth failed — user not authenticated)
|
||||
// • Not 403 Forbidden (auth failed — user lacks required role)
|
||||
// • If 302/Found, the Location must NOT point to /login (bounced due to auth)
|
||||
// A 500 or 200 both mean the auth gate was cleared.
|
||||
|
||||
public static readonly TheoryData<string> CanPublishPagesForPermitTest = new()
|
||||
{
|
||||
"/clusters/new",
|
||||
"/reservations",
|
||||
"/certificates",
|
||||
"/role-grants",
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CanPublishPagesForPermitTest))]
|
||||
public async Task FleetAdmin_is_permitted_CanPublish_gated_page(string route)
|
||||
{
|
||||
using var client = _factory.CreateClientWithRoles(AdminRoles.FleetAdmin);
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden,
|
||||
$"FleetAdmin GET {route} must not be denied — FleetAdmin satisfies CanPublish");
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Unauthorized,
|
||||
$"FleetAdmin GET {route} must not be denied");
|
||||
|
||||
// May be 200, 500 (DB error — not auth error), or a redirect within session.
|
||||
if (response.StatusCode == HttpStatusCode.Redirect ||
|
||||
response.StatusCode == HttpStatusCode.Found)
|
||||
{
|
||||
response.Headers.Location!.OriginalString.ShouldNotContain("/login",
|
||||
Case.Insensitive, $"FleetAdmin GET {route} must not be bounced to login");
|
||||
}
|
||||
}
|
||||
|
||||
// ── (d) ConfigViewer is denied CanPublish pages ───────────────────────────────
|
||||
|
||||
public static readonly TheoryData<string> CanPublishPages = new()
|
||||
{
|
||||
"/clusters/new", // [Authorize(Policy = "CanPublish")]
|
||||
"/reservations", // [Authorize(Policy = "CanPublish")]
|
||||
"/role-grants", // [Authorize(Policy = "CanPublish")]
|
||||
"/certificates", // [Authorize(Policy = "CanPublish")]
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CanPublishPages))]
|
||||
public async Task ConfigViewer_is_denied_CanPublish_gated_page(string route)
|
||||
{
|
||||
// ConfigViewer has no FleetAdmin role, so the CanPublish policy must deny access.
|
||||
using var client = _factory.CreateClientWithRoles(AdminRoles.ConfigViewer);
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
|
||||
// A 403 Forbidden is the expected outcome for an authenticated user who lacks
|
||||
// the required role. A 302 to /login is also acceptable (the cookie scheme may
|
||||
// redirect, but the real gate is the role check, not authentication).
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.OK,
|
||||
$"ConfigViewer GET {route} must be denied — CanPublish requires FleetAdmin");
|
||||
|
||||
response.StatusCode.ShouldBeOneOf(
|
||||
HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized,
|
||||
HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
}
|
||||
|
||||
// ── WebApplicationFactory plumbing ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="WebApplicationFactory{TEntryPoint}"/> that replaces the cookie
|
||||
/// authentication scheme with a custom handler that stamps requests with a
|
||||
/// caller-supplied role set. Tests obtain a per-role <see cref="HttpClient"/> via
|
||||
/// <see cref="CreateClientWithRoles"/>.
|
||||
///
|
||||
/// Role injection works through a singleton <see cref="RoleContext"/> that holds a
|
||||
/// simple <c>lock</c>-protected field. This avoids the <c>AsyncLocal</c>-does-not-flow-
|
||||
/// into-TestServer pitfall and the stale-<c>[ThreadStatic]</c> pitfall.
|
||||
/// </summary>
|
||||
public sealed class RoleInjectingAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton shared by the factory and the <see cref="RoleInjectingHandler"/>.
|
||||
/// Holds the roles that the next request should authenticate as.
|
||||
/// </summary>
|
||||
internal sealed class RoleContext
|
||||
{
|
||||
private readonly Lock _lock = new();
|
||||
private string[] _roles = [];
|
||||
|
||||
public void SetRoles(string[] roles) { lock (_lock) { _roles = roles; } }
|
||||
public void Clear() { lock (_lock) { _roles = []; } }
|
||||
public string[] GetRoles() { lock (_lock) { return _roles; } }
|
||||
}
|
||||
|
||||
// Initialized here so it is available before CreateHost is invoked (the factory
|
||||
// builds the host lazily on first client creation; _roleContext must not be null
|
||||
// at CreateClientWithRoles() time, and the singleton registered in CreateHost
|
||||
// must be the same instance as this field).
|
||||
private readonly RoleContext _roleContext = new();
|
||||
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
var ctx = _roleContext;
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove the background poller: it would start a DB poll loop that fails
|
||||
// without the central SQL Server.
|
||||
var poller = services.SingleOrDefault(d =>
|
||||
d.ImplementationType?.Name == "FleetStatusPoller");
|
||||
if (poller is not null) services.Remove(poller);
|
||||
|
||||
// Remove the LDAP auth service to avoid accidental LDAP calls.
|
||||
var ldap = services.SingleOrDefault(d => d.ServiceType == typeof(ILdapAuthService));
|
||||
if (ldap is not null) services.Remove(ldap);
|
||||
services.AddScoped<ILdapAuthService, NullLdapAuthService>();
|
||||
|
||||
// Register the shared RoleContext as a singleton so the handler can read it.
|
||||
services.AddSingleton(ctx);
|
||||
|
||||
// Register the role-injecting test scheme and override the default schemes.
|
||||
services.AddAuthentication()
|
||||
.AddScheme<AuthenticationSchemeOptions, RoleInjectingHandler>(
|
||||
RoleInjectingHandler.SchemeName, _ => { });
|
||||
|
||||
services.PostConfigure<AuthenticationOptions>(opt =>
|
||||
{
|
||||
opt.DefaultAuthenticateScheme = RoleInjectingHandler.SchemeName;
|
||||
opt.DefaultChallengeScheme = RoleInjectingHandler.SchemeName;
|
||||
opt.DefaultForbidScheme = RoleInjectingHandler.SchemeName;
|
||||
});
|
||||
});
|
||||
return base.CreateHost(builder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an <see cref="HttpClient"/> that authenticates every request with
|
||||
/// the given <paramref name="roles"/>.
|
||||
/// </summary>
|
||||
public HttpClient CreateClientWithRoles(params string[] roles)
|
||||
{
|
||||
_roleContext!.SetRoles(roles);
|
||||
return CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authentication handler that stamps the current request with the roles stored in
|
||||
/// the <see cref="RoleInjectingAppFactory.RoleContext"/> singleton.
|
||||
/// </summary>
|
||||
private sealed class RoleInjectingHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
RoleInjectingAppFactory.RoleContext roleContext)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "RoleInjecting";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var roles = roleContext.GetRoles();
|
||||
if (roles.Length == 0)
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, "test-operator"),
|
||||
new(ClaimTypes.NameIdentifier, "test-operator"),
|
||||
};
|
||||
foreach (var role in roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Null LDAP auth service — never called in these tests.</summary>
|
||||
private sealed class NullLdapAuthService : ILdapAuthService
|
||||
{
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
|
||||
Task.FromResult(new LdapAuthResult(false, null, username, [], [], "LDAP disabled in auth-pipeline tests"));
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AdminRoleGrantResolverTests
|
||||
{
|
||||
/// <summary>In-memory <see cref="ILdapGroupRoleMappingService"/> — only the read path is exercised.</summary>
|
||||
private sealed class FakeMappingService(IReadOnlyList<LdapGroupRoleMapping> rows) : ILdapGroupRoleMappingService
|
||||
{
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
var set = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(
|
||||
rows.Where(r => set.Contains(r.LdapGroup)).ToList());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(rows);
|
||||
|
||||
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static AdminRoleGrantResolver Resolver(
|
||||
IReadOnlyList<LdapGroupRoleMapping> rows, Dictionary<string, string>? staticMap = null)
|
||||
{
|
||||
var options = Options.Create(new LdapOptions
|
||||
{
|
||||
GroupToRole = staticMap ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
|
||||
});
|
||||
return new AdminRoleGrantResolver(new FakeMappingService(rows), options);
|
||||
}
|
||||
|
||||
private static LdapGroupRoleMapping Row(string group, AdminRole role, bool systemWide, string? clusterId)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LdapGroup = group,
|
||||
Role = role,
|
||||
IsSystemWide = systemWide,
|
||||
ClusterId = clusterId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task No_groups_resolves_to_empty()
|
||||
{
|
||||
var grants = await Resolver([]).ResolveAsync([], CancellationToken.None);
|
||||
grants.IsEmpty.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Static_dictionary_grant_is_fleet_wide()
|
||||
{
|
||||
var resolver = Resolver([], new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
});
|
||||
|
||||
var grants = await resolver.ResolveAsync(["ReadOnly"], CancellationToken.None);
|
||||
|
||||
grants.FleetRoles.ShouldBe(["ConfigViewer"]);
|
||||
grants.ClusterRoles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task System_wide_db_row_lands_in_fleet_roles()
|
||||
{
|
||||
var resolver = Resolver([Row("cn=admins", AdminRole.FleetAdmin, systemWide: true, clusterId: null)]);
|
||||
|
||||
var grants = await resolver.ResolveAsync(["cn=admins"], CancellationToken.None);
|
||||
|
||||
grants.FleetRoles.ShouldBe(["FleetAdmin"]);
|
||||
grants.ClusterRoles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cluster_scoped_db_row_lands_in_cluster_roles()
|
||||
{
|
||||
var resolver = Resolver([Row("cn=warsaw", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW")]);
|
||||
|
||||
var grants = await resolver.ResolveAsync(["cn=warsaw"], CancellationToken.None);
|
||||
|
||||
grants.FleetRoles.ShouldBeEmpty();
|
||||
grants.ClusterRoles.Count.ShouldBe(1);
|
||||
grants.ClusterRoles[0].ShouldBe(new ClusterRoleGrant("WARSAW", "ConfigEditor"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_group_can_hold_different_roles_on_different_clusters()
|
||||
{
|
||||
var resolver = Resolver(
|
||||
[
|
||||
Row("cn=ops", AdminRole.FleetAdmin, systemWide: false, clusterId: "WARSAW"),
|
||||
Row("cn=ops", AdminRole.ConfigViewer, systemWide: false, clusterId: "BERLIN"),
|
||||
]);
|
||||
|
||||
var grants = await resolver.ResolveAsync(["cn=ops"], CancellationToken.None);
|
||||
|
||||
grants.ClusterRoles.ShouldContain(new ClusterRoleGrant("WARSAW", "FleetAdmin"));
|
||||
grants.ClusterRoles.ShouldContain(new ClusterRoleGrant("BERLIN", "ConfigViewer"));
|
||||
grants.ClusterRoles.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Db_grants_stack_additively_on_the_static_bootstrap()
|
||||
{
|
||||
var resolver = Resolver(
|
||||
[Row("cn=ops", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW")],
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["cn=ops"] = "ConfigViewer" });
|
||||
|
||||
var grants = await resolver.ResolveAsync(["cn=ops"], CancellationToken.None);
|
||||
|
||||
grants.FleetRoles.ShouldBe(["ConfigViewer"]);
|
||||
grants.ClusterRoles.ShouldBe([new ClusterRoleGrant("WARSAW", "ConfigEditor")]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_cluster_role_pair_is_deduped()
|
||||
{
|
||||
var resolver = Resolver(
|
||||
[
|
||||
Row("cn=a", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW"),
|
||||
Row("cn=b", AdminRole.ConfigEditor, systemWide: false, clusterId: "WARSAW"),
|
||||
]);
|
||||
|
||||
var grants = await resolver.ResolveAsync(["cn=a", "cn=b"], CancellationToken.None);
|
||||
|
||||
grants.ClusterRoles.ShouldBe([new ClusterRoleGrant("WARSAW", "ConfigEditor")]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Groups_with_no_mapping_resolve_to_empty()
|
||||
{
|
||||
var grants = await Resolver([]).ResolveAsync(["cn=nobody"], CancellationToken.None);
|
||||
grants.IsEmpty.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AdminRolesTests
|
||||
{
|
||||
[Fact]
|
||||
public void All_contains_three_canonical_roles()
|
||||
{
|
||||
AdminRoles.All.Count.ShouldBe(3);
|
||||
AdminRoles.All.ShouldContain(AdminRoles.ConfigViewer);
|
||||
AdminRoles.All.ShouldContain(AdminRoles.ConfigEditor);
|
||||
AdminRoles.All.ShouldContain(AdminRoles.FleetAdmin);
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ties Admin services end-to-end against a throwaway per-run database — mirrors the
|
||||
/// Configuration fixture pattern. Spins up a fresh DB, applies migrations, exercises the
|
||||
/// create-cluster → add-equipment → validate → publish → rollback happy path, then drops the
|
||||
/// DB in Dispose. Confirms the stored procedures and managed validators agree with the UI
|
||||
/// services.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class AdminServicesIntegrationTests : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "10.100.0.35,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
private readonly string _databaseName = $"OtOpcUaAdminTest_{Guid.NewGuid():N}";
|
||||
private readonly string _connectionString;
|
||||
|
||||
public AdminServicesIntegrationTests()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
_connectionString =
|
||||
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.Migrate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
|
||||
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString)
|
||||
{ InitialCatalog = "master" }.ConnectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
IF DB_ID(N'{_databaseName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{_databaseName}];
|
||||
END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(_connectionString)
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_cluster_add_equipment_validate_publish_roundtrips_the_full_admin_flow()
|
||||
{
|
||||
// 1. Create cluster + draft.
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var clusterSvc = new ClusterService(ctx);
|
||||
await clusterSvc.CreateAsync(new ServerCluster
|
||||
{
|
||||
ClusterId = "flow-1", Name = "Flow test", Enterprise = "zb", Site = "dev",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true,
|
||||
CreatedBy = "test",
|
||||
}, createdBy: "test", CancellationToken.None);
|
||||
}
|
||||
|
||||
long draftId;
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var genSvc = new GenerationService(ctx);
|
||||
var draft = await genSvc.CreateDraftAsync("flow-1", "test", CancellationToken.None);
|
||||
draftId = draft.GenerationId;
|
||||
}
|
||||
|
||||
// 2. Add namespace + UNS + driver + equipment.
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var nsSvc = new NamespaceService(ctx);
|
||||
var unsSvc = new UnsService(ctx);
|
||||
var drvSvc = new DriverInstanceService(ctx);
|
||||
var eqSvc = new EquipmentService(ctx);
|
||||
|
||||
var ns = await nsSvc.AddAsync(draftId, "flow-1", "urn:flow:ns", NamespaceKind.Equipment, CancellationToken.None);
|
||||
var area = await unsSvc.AddAreaAsync(draftId, "flow-1", "line-a", null, CancellationToken.None);
|
||||
var line = await unsSvc.AddLineAsync(draftId, area.UnsAreaId, "cell-1", null, CancellationToken.None);
|
||||
var driver = await drvSvc.AddAsync(draftId, "flow-1", ns.NamespaceId, "modbus", "ModbusTcp", "{}", CancellationToken.None);
|
||||
|
||||
await eqSvc.CreateAsync(draftId, new Equipment
|
||||
{
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
EquipmentId = string.Empty,
|
||||
DriverInstanceId = driver.DriverInstanceId,
|
||||
UnsLineId = line.UnsLineId,
|
||||
Name = "eq-1",
|
||||
MachineCode = "M001",
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
// 3. Validate — should be error-free.
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var validationSvc = new DraftValidationService(ctx);
|
||||
var errors = await validationSvc.ValidateAsync(draftId, CancellationToken.None);
|
||||
errors.ShouldBeEmpty("draft with matched namespace/driver should validate clean");
|
||||
}
|
||||
|
||||
// 4. Publish + verify status flipped.
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var genSvc = new GenerationService(ctx);
|
||||
await genSvc.PublishAsync("flow-1", draftId, "first publish", CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var status = await ctx.ConfigGenerations
|
||||
.Where(g => g.GenerationId == draftId)
|
||||
.Select(g => g.Status)
|
||||
.FirstAsync();
|
||||
status.ShouldBe(GenerationStatus.Published);
|
||||
}
|
||||
|
||||
// 5. Rollback creates a new Published generation cloned from the target.
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var genSvc = new GenerationService(ctx);
|
||||
await genSvc.RollbackAsync("flow-1", draftId, "rollback test", CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var publishedCount = await ctx.ConfigGenerations
|
||||
.CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Published);
|
||||
publishedCount.ShouldBe(1, "rollback supersedes the prior publish with a new one");
|
||||
|
||||
var supersededCount = await ctx.ConfigGenerations
|
||||
.CountAsync(g => g.ClusterId == "flow-1" && g.Status == GenerationStatus.Superseded);
|
||||
supersededCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_draft_surfaces_cross_cluster_namespace_binding_violation()
|
||||
{
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
await new ClusterService(ctx).CreateAsync(new ServerCluster
|
||||
{
|
||||
ClusterId = "c-A", Name = "A", Enterprise = "zb", Site = "dev",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
|
||||
}, "t", CancellationToken.None);
|
||||
|
||||
await new ClusterService(ctx).CreateAsync(new ServerCluster
|
||||
{
|
||||
ClusterId = "c-B", Name = "B", Enterprise = "zb", Site = "dev",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
|
||||
}, "t", CancellationToken.None);
|
||||
}
|
||||
|
||||
long draftId;
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var draft = await new GenerationService(ctx).CreateDraftAsync("c-A", "t", CancellationToken.None);
|
||||
draftId = draft.GenerationId;
|
||||
}
|
||||
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
// Namespace rooted in c-B, driver in c-A — decision #122 violation.
|
||||
var ns = await new NamespaceService(ctx)
|
||||
.AddAsync(draftId, "c-B", "urn:cross", NamespaceKind.Equipment, CancellationToken.None);
|
||||
await new DriverInstanceService(ctx)
|
||||
.AddAsync(draftId, "c-A", ns.NamespaceId, "drv", "ModbusTcp", "{}", CancellationToken.None);
|
||||
}
|
||||
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
var errors = await new DraftValidationService(ctx).ValidateAsync(draftId, CancellationToken.None);
|
||||
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for Admin-004 — the committed <c>appsettings.json</c> must carry no
|
||||
/// plaintext secrets. The <c>ConfigDb</c> connection string and the LDAP
|
||||
/// <c>ServiceAccountPassword</c> are supplied at runtime via user-secrets (dev) or
|
||||
/// environment variables (prod); the checked-in file holds only empty placeholders.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AppSettingsSecretHygieneTests
|
||||
{
|
||||
private static JsonDocument LoadAdminAppSettings()
|
||||
{
|
||||
// Walk up from the test assembly to the repo root (the dir holding the .slnx) and
|
||||
// read the SOURCE appsettings.json — not a bin/ copy — so the test asserts on what
|
||||
// is actually committed.
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
|
||||
dir.ShouldNotBeNull("could not locate the repo root (ZB.MOM.WW.OtOpcUa.slnx)");
|
||||
var path = Path.Combine(dir, "src", "Server", "ZB.MOM.WW.OtOpcUa.Admin", "appsettings.json");
|
||||
File.Exists(path).ShouldBeTrue($"Admin appsettings.json not found at {path}");
|
||||
|
||||
return JsonDocument.Parse(File.ReadAllText(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigDb_connection_string_is_an_empty_placeholder()
|
||||
{
|
||||
using var doc = LoadAdminAppSettings();
|
||||
|
||||
var connectionString = doc.RootElement
|
||||
.GetProperty("ConnectionStrings")
|
||||
.GetProperty("ConfigDb")
|
||||
.GetString();
|
||||
|
||||
connectionString.ShouldBeNullOrEmpty(
|
||||
"the ConfigDb connection string must not be committed — supply it via user-secrets " +
|
||||
"or the ConnectionStrings__ConfigDb environment variable (Admin-004)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ldap_service_account_password_is_an_empty_placeholder()
|
||||
{
|
||||
using var doc = LoadAdminAppSettings();
|
||||
|
||||
var password = doc.RootElement
|
||||
.GetProperty("Authentication")
|
||||
.GetProperty("Ldap")
|
||||
.GetProperty("ServiceAccountPassword")
|
||||
.GetString();
|
||||
|
||||
password.ShouldBeNullOrEmpty(
|
||||
"the LDAP ServiceAccountPassword must not be committed (Admin-004)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_known_dev_secret_literals_appear_anywhere_in_appsettings()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
dir.ShouldNotBeNull();
|
||||
|
||||
var raw = File.ReadAllText(Path.Combine(
|
||||
dir, "src", "Server", "ZB.MOM.WW.OtOpcUa.Admin", "appsettings.json"));
|
||||
|
||||
// The exact secret literals the review (Admin-004) flagged must be gone entirely —
|
||||
// not relocated to another key, not present as a comment.
|
||||
raw.ShouldNotContain("OtOpcUaDev_2026!");
|
||||
raw.ShouldNotContain("serviceaccount123");
|
||||
raw.ShouldNotContain("User Id=sa");
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for Admin-003 / Admin-005.
|
||||
///
|
||||
/// Admin-005 — the login is a static-rendered form posting to the <c>/auth/login</c>
|
||||
/// minimal-API endpoint, which performs the LDAP bind, cookie <c>SignInAsync</c> and
|
||||
/// redirect while it still owns the HTTP response (no interactive Blazor circuit).
|
||||
///
|
||||
/// Admin-003 — the three SignalR hubs reject anonymous connections.
|
||||
///
|
||||
/// These are HTTP-pipeline tests with a stubbed <see cref="ILdapAuthService"/>, so they
|
||||
/// run without LDAP or the central SQL Server.
|
||||
/// </summary>
|
||||
public sealed class AuthEndpointsTests : IClassFixture<AuthEndpointsTests.StubbedAuthAppFactory>
|
||||
{
|
||||
private readonly StubbedAuthAppFactory _factory;
|
||||
|
||||
public AuthEndpointsTests(StubbedAuthAppFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>
|
||||
/// Admin app host with the LDAP service stubbed (a fixed-credential pass/fail) and the
|
||||
/// background poller removed so the host starts clean without DB or directory access.
|
||||
/// </summary>
|
||||
public sealed class StubbedAuthAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var poller = services.SingleOrDefault(d =>
|
||||
d.ImplementationType?.Name == "FleetStatusPoller");
|
||||
if (poller is not null) services.Remove(poller);
|
||||
|
||||
var ldap = services.SingleOrDefault(d => d.ServiceType == typeof(ILdapAuthService));
|
||||
if (ldap is not null) services.Remove(ldap);
|
||||
services.AddScoped<ILdapAuthService, StubLdapAuthService>();
|
||||
|
||||
var resolver = services.SingleOrDefault(d => d.ServiceType == typeof(IAdminRoleGrantResolver));
|
||||
if (resolver is not null) services.Remove(resolver);
|
||||
services.AddScoped<IAdminRoleGrantResolver, StubRoleGrantResolver>();
|
||||
});
|
||||
return base.CreateHost(builder);
|
||||
}
|
||||
|
||||
public HttpClient CreateNonRedirectingClient() =>
|
||||
CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
}
|
||||
|
||||
/// <summary>Stub LDAP: <c>good</c>/<c>pw</c> binds; anything else fails.</summary>
|
||||
private sealed class StubLdapAuthService : ILdapAuthService
|
||||
{
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
|
||||
Task.FromResult(username == "good" && password == "pw"
|
||||
? new LdapAuthResult(true, "Good Operator", "good", ["FleetAdmins"], ["FleetAdmin"], null)
|
||||
: new LdapAuthResult(false, null, username, [], [], "Invalid username or password"));
|
||||
}
|
||||
|
||||
/// <summary>Stub resolver: any non-empty group set yields a FleetAdmin grant.</summary>
|
||||
private sealed class StubRoleGrantResolver : IAdminRoleGrantResolver
|
||||
{
|
||||
public Task<AdminRoleGrants> ResolveAsync(IReadOnlyList<string> ldapGroups, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(ldapGroups.Count == 0
|
||||
? AdminRoleGrants.Empty
|
||||
: new AdminRoleGrants([AdminRoles.FleetAdmin], []));
|
||||
}
|
||||
|
||||
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
||||
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
||||
|
||||
// ── Admin-005: /auth/login endpoint ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_login_issues_the_auth_cookie_and_redirects_home()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
var response = await client.PostAsync("/auth/login",
|
||||
Form(("username", "good"), ("password", "pw")));
|
||||
|
||||
// The endpoint owns the response, so the Set-Cookie header is actually emitted.
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
response.Headers.Location!.OriginalString.ShouldBe("/");
|
||||
response.Headers.TryGetValues("Set-Cookie", out var cookies).ShouldBeTrue(
|
||||
"a successful /auth/login must emit the auth cookie");
|
||||
string.Join(';', cookies!).ShouldContain("OtOpcUa.Admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_login_redirects_back_to_login_with_an_error()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
var response = await client.PostAsync("/auth/login",
|
||||
Form(("username", "bad"), ("password", "wrong")));
|
||||
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
response.Headers.Location!.OriginalString.ShouldContain("/login");
|
||||
response.Headers.Location!.OriginalString.ShouldContain("error");
|
||||
response.Headers.TryGetValues("Set-Cookie", out var cookies);
|
||||
(cookies is null || !string.Join(';', cookies).Contains("OtOpcUa.Admin")).ShouldBeTrue(
|
||||
"a failed bind must not issue the auth cookie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_with_missing_credentials_redirects_back_to_login()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
var response = await client.PostAsync("/auth/login", Form(("username", ""), ("password", "")));
|
||||
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
response.Headers.Location!.OriginalString.ShouldContain("/login");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_redirect_target_is_open_redirect_safe()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
// A returnUrl pointing off-site must be ignored — the post lands at the site root.
|
||||
var response = await client.PostAsync("/auth/login",
|
||||
Form(("username", "good"), ("password", "pw"), ("returnUrl", "https://evil.example.com/")));
|
||||
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
response.Headers.Location!.OriginalString.ShouldBe("/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_honours_a_local_return_url()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
var response = await client.PostAsync("/auth/login",
|
||||
Form(("username", "good"), ("password", "pw"), ("returnUrl", "/fleet")));
|
||||
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
response.Headers.Location!.OriginalString.ShouldBe("/fleet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Logout_with_valid_session_but_no_antiforgery_token_is_rejected()
|
||||
{
|
||||
// Admin-006: the logout endpoint no longer calls .DisableAntiforgery(), so the
|
||||
// UseAntiforgery() middleware must reject a POST that carries no token with 400.
|
||||
// This regression guards against CSRF-logout (attacker tricking the operator's
|
||||
// already-authenticated browser into posting to /auth/logout from a foreign origin).
|
||||
//
|
||||
// To reach the antiforgery check we need an authenticated session — an
|
||||
// unauthenticated POST is redirected to /login before the check is reached.
|
||||
// We obtain the auth cookie via a valid /auth/login round-trip first.
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
// Step 1: log in to get the session cookie.
|
||||
var loginResponse = await client.PostAsync("/auth/login",
|
||||
Form(("username", "good"), ("password", "pw")));
|
||||
loginResponse.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
|
||||
// The cookie jar on the client now holds the auth cookie; subsequent requests are
|
||||
// authenticated. Step 2: POST to /auth/logout without an antiforgery token.
|
||||
var logoutResponse = await client.PostAsync("/auth/logout",
|
||||
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
|
||||
|
||||
// The antiforgery middleware must reject the missing token with 400.
|
||||
logoutResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest,
|
||||
"/auth/logout from an authenticated session without an antiforgery token must be rejected (Admin-006)");
|
||||
}
|
||||
|
||||
// ── Admin-003: SignalR hubs reject anonymous connections ────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("/hubs/fleet")]
|
||||
[InlineData("/hubs/alerts")]
|
||||
[InlineData("/hubs/script-log")]
|
||||
public async Task Anonymous_hub_negotiate_is_rejected(string hubPath)
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
// The SignalR negotiate handshake is a POST to <hub>/negotiate. An [Authorize]'d hub
|
||||
// must refuse it for an unauthenticated caller (302 to login or 401).
|
||||
var response = await client.PostAsync($"{hubPath}/negotiate",
|
||||
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
|
||||
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.OK,
|
||||
$"anonymous negotiate of {hubPath} must not succeed — the hub is [Authorize]-gated");
|
||||
response.StatusCode.ShouldBeOneOf(
|
||||
HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden,
|
||||
HttpStatusCode.Redirect, HttpStatusCode.Found);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Admin-010 — admin-ui.md "Tech Stack" requires Bootstrap 5
|
||||
/// "vendored under wwwroot/lib/bootstrap/" so the Admin app has no third-party
|
||||
/// runtime dependency and works in air-gapped fleet deployments. These tests
|
||||
/// guard against a future re-introduction of the cdn.jsdelivr.net references
|
||||
/// in App.razor.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BootstrapVendoringTests
|
||||
{
|
||||
[Fact]
|
||||
public void AppRazor_does_not_reference_a_remote_CDN_for_bootstrap()
|
||||
{
|
||||
var appRazor = File.ReadAllText(ResolveAdminPath("Components/App.razor"));
|
||||
|
||||
appRazor.ShouldNotContain("cdn.jsdelivr.net",
|
||||
customMessage: "Admin-010: Bootstrap must be served from the vendored copy under wwwroot/lib/bootstrap/, not jsDelivr — air-gapped deployments cannot reach the public CDN.");
|
||||
appRazor.ShouldNotContain("cdnjs.cloudflare.com",
|
||||
customMessage: "Admin-010: third-party CDN references regress the vendoring requirement.");
|
||||
appRazor.ShouldNotContain("unpkg.com",
|
||||
customMessage: "Admin-010: third-party CDN references regress the vendoring requirement.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppRazor_references_vendored_bootstrap_assets()
|
||||
{
|
||||
var appRazor = File.ReadAllText(ResolveAdminPath("Components/App.razor"));
|
||||
|
||||
appRazor.ShouldContain("lib/bootstrap/css/bootstrap.min.css",
|
||||
customMessage: "App.razor must load the vendored Bootstrap stylesheet.");
|
||||
appRazor.ShouldContain("lib/bootstrap/js/bootstrap.bundle.min.js",
|
||||
customMessage: "App.razor must load the vendored Bootstrap JS bundle.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vendored_bootstrap_assets_exist_under_wwwroot_lib_bootstrap()
|
||||
{
|
||||
var root = ResolveAdminPath("wwwroot/lib/bootstrap");
|
||||
|
||||
Directory.Exists(root).ShouldBeTrue($"expected vendored bootstrap directory at '{root}'");
|
||||
File.Exists(Path.Combine(root, "css", "bootstrap.min.css")).ShouldBeTrue("bootstrap.min.css missing");
|
||||
File.Exists(Path.Combine(root, "js", "bootstrap.bundle.min.js")).ShouldBeTrue("bootstrap.bundle.min.js missing");
|
||||
|
||||
// Sanity-check non-empty (a zero-byte placeholder would still pass File.Exists).
|
||||
new FileInfo(Path.Combine(root, "css", "bootstrap.min.css")).Length.ShouldBeGreaterThan(100_000);
|
||||
new FileInfo(Path.Combine(root, "js", "bootstrap.bundle.min.js")).Length.ShouldBeGreaterThan(50_000);
|
||||
}
|
||||
|
||||
/// <summary>Resolve a path under the Admin source project from the test runner's bin folder.</summary>
|
||||
private static string ResolveAdminPath(string relative)
|
||||
{
|
||||
var asmDir = Path.GetDirectoryName(typeof(BootstrapVendoringTests).Assembly.Location)!;
|
||||
// tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/bin/Debug/net10.0 -> ../../../../../src/Server/...
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(asmDir, "..", "..", "..", "..", "..", ".."));
|
||||
return Path.Combine(repoRoot, "src", "Server", "ZB.MOM.WW.OtOpcUa.Admin", relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CertTrustServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public CertTrustServiceTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
|
||||
private CertTrustService Service() => new(
|
||||
Options.Create(new CertTrustOptions { PkiStoreRoot = _root }),
|
||||
NullLogger<CertTrustService>.Instance);
|
||||
|
||||
private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||
var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
||||
var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der");
|
||||
File.WriteAllBytes(path, cert.Export(X509ContentType.Cert));
|
||||
return cert;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir()
|
||||
{
|
||||
var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A");
|
||||
|
||||
var rows = Service().ListRejected();
|
||||
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
|
||||
rows[0].Subject.ShouldContain("test-client-A");
|
||||
rows[0].Store.ShouldBe(CertStoreKind.Rejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListTrusted_is_separate_from_rejected()
|
||||
{
|
||||
WriteTestCert(CertStoreKind.Rejected, "rej");
|
||||
WriteTestCert(CertStoreKind.Trusted, "trust");
|
||||
|
||||
var svc = Service();
|
||||
svc.ListRejected().Count.ShouldBe(1);
|
||||
svc.ListTrusted().Count.ShouldBe(1);
|
||||
svc.ListRejected()[0].Subject.ShouldContain("rej");
|
||||
svc.ListTrusted()[0].Subject.ShouldContain("trust");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustRejected_moves_file_from_rejected_to_trusted()
|
||||
{
|
||||
var c = WriteTestCert(CertStoreKind.Rejected, "promoteme");
|
||||
var svc = Service();
|
||||
|
||||
svc.TrustRejected(c.Thumbprint).ShouldBeTrue();
|
||||
|
||||
svc.ListRejected().ShouldBeEmpty();
|
||||
var trusted = svc.ListTrusted();
|
||||
trusted.Count.ShouldBe(1);
|
||||
trusted[0].Thumbprint.ShouldBe(c.Thumbprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrustRejected_returns_false_when_thumbprint_not_in_rejected()
|
||||
{
|
||||
var svc = Service();
|
||||
svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteRejected_removes_the_file()
|
||||
{
|
||||
var c = WriteTestCert(CertStoreKind.Rejected, "killme");
|
||||
var svc = Service();
|
||||
|
||||
svc.DeleteRejected(c.Thumbprint).ShouldBeTrue();
|
||||
svc.ListRejected().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntrustCert_removes_from_trusted_only()
|
||||
{
|
||||
var c = WriteTestCert(CertStoreKind.Trusted, "revoke");
|
||||
var svc = Service();
|
||||
|
||||
svc.UntrustCert(c.Thumbprint).ShouldBeTrue();
|
||||
svc.ListTrusted().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Thumbprint_match_is_case_insensitive()
|
||||
{
|
||||
var c = WriteTestCert(CertStoreKind.Rejected, "case");
|
||||
var svc = Service();
|
||||
|
||||
// X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often
|
||||
// lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving.
|
||||
svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_store_directories_produce_empty_lists_not_exceptions()
|
||||
{
|
||||
// Fresh root with no certs subfolder — service should tolerate a pristine install.
|
||||
var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
var svc = new CertTrustService(
|
||||
Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }),
|
||||
NullLogger<CertTrustService>.Instance);
|
||||
svc.ListRejected().ShouldBeEmpty();
|
||||
svc.ListTrusted().ShouldBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_file_is_skipped_not_fatal()
|
||||
{
|
||||
// Drop junk bytes that don't parse as a cert into the rejected/certs directory. The
|
||||
// service must skip it and still return the valid certs — one bad file can't take the
|
||||
// whole management page offline.
|
||||
File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert");
|
||||
var c = WriteTestCert(CertStoreKind.Rejected, "valid");
|
||||
|
||||
var rows = Service().ListRejected();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].Thumbprint.ShouldBe(c.Thumbprint);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ClusterNodeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsStale_NullLastSeen_Returns_True()
|
||||
{
|
||||
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: null);
|
||||
ClusterNodeService.IsStale(node).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStale_RecentLastSeen_Returns_False()
|
||||
{
|
||||
var node = NewNode("A", RedundancyRole.Primary, lastSeenAt: DateTime.UtcNow.AddSeconds(-5));
|
||||
ClusterNodeService.IsStale(node).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsStale_Old_LastSeen_Returns_True()
|
||||
{
|
||||
var node = NewNode("A", RedundancyRole.Primary,
|
||||
lastSeenAt: DateTime.UtcNow - ClusterNodeService.StaleThreshold - TimeSpan.FromSeconds(1));
|
||||
ClusterNodeService.IsStale(node).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByClusterAsync_OrdersByServiceLevelBase_Descending_Then_NodeId()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.ClusterNodes.AddRange(
|
||||
NewNode("B-low", RedundancyRole.Secondary, serviceLevelBase: 150, clusterId: "c1"),
|
||||
NewNode("A-high", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c1"),
|
||||
NewNode("other-cluster", RedundancyRole.Primary, serviceLevelBase: 200, clusterId: "c2"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new ClusterNodeService(ctx);
|
||||
var rows = await svc.ListByClusterAsync("c1", CancellationToken.None);
|
||||
|
||||
rows.Count.ShouldBe(2);
|
||||
rows[0].NodeId.ShouldBe("A-high"); // higher ServiceLevelBase first
|
||||
rows[1].NodeId.ShouldBe("B-low");
|
||||
}
|
||||
|
||||
private static ClusterNode NewNode(
|
||||
string nodeId,
|
||||
RedundancyRole role,
|
||||
DateTime? lastSeenAt = null,
|
||||
int serviceLevelBase = 200,
|
||||
string clusterId = "c1") => new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = clusterId,
|
||||
RedundancyRole = role,
|
||||
Host = $"{nodeId}.example",
|
||||
ApplicationUri = $"urn:{nodeId}",
|
||||
ServiceLevelBase = (byte)serviceLevelBase,
|
||||
LastSeenAt = lastSeenAt,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ClusterRoleClaimsTests
|
||||
{
|
||||
private static ClaimsPrincipal User(params Claim[] claims)
|
||||
=> new(new ClaimsIdentity(claims, authenticationType: "test"));
|
||||
|
||||
private static Claim Fleet(string role) => new(ClaimTypes.Role, role);
|
||||
|
||||
private static Claim Cluster(string clusterId, AdminRole role)
|
||||
=> new(ClusterRoleClaims.ClaimType, ClusterRoleClaims.Encode(clusterId, role.ToString()));
|
||||
|
||||
[Fact]
|
||||
public void Encode_then_decode_roundtrips()
|
||||
{
|
||||
var decoded = ClusterRoleClaims.Decode(ClusterRoleClaims.Encode("WARSAW", "FleetAdmin"));
|
||||
decoded.ShouldNotBeNull();
|
||||
decoded!.Value.ClusterId.ShouldBe("WARSAW");
|
||||
decoded.Value.Role.ShouldBe("FleetAdmin");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("nosseparator")]
|
||||
public void Decode_malformed_value_returns_null(string value)
|
||||
=> ClusterRoleClaims.Decode(value).ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Effective_role_for_cluster_uses_fleet_wide_grant()
|
||||
{
|
||||
var user = User(Fleet("ConfigEditor"));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.ConfigEditor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effective_role_uses_cluster_scoped_grant_for_the_named_cluster()
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.FleetAdmin));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.FleetAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_scoped_grant_does_not_leak_to_another_cluster()
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.FleetAdmin));
|
||||
user.EffectiveClusterRole("BERLIN").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cluster_match_is_case_insensitive()
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.ConfigViewer));
|
||||
user.EffectiveClusterRole("warsaw").ShouldBe(AdminRole.ConfigViewer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Effective_role_is_the_highest_of_fleet_and_cluster_grants()
|
||||
{
|
||||
var user = User(Fleet("ConfigViewer"), Cluster("WARSAW", AdminRole.FleetAdmin));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.FleetAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fleet_grant_wins_when_higher_than_the_cluster_grant()
|
||||
{
|
||||
var user = User(Fleet("FleetAdmin"), Cluster("WARSAW", AdminRole.ConfigViewer));
|
||||
user.EffectiveClusterRole("WARSAW").ShouldBe(AdminRole.FleetAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_grants_yields_null_effective_role()
|
||||
=> User().EffectiveClusterRole("WARSAW").ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData(AdminRole.ConfigViewer, true)]
|
||||
[InlineData(AdminRole.ConfigEditor, true)]
|
||||
[InlineData(AdminRole.FleetAdmin, false)]
|
||||
public void Has_cluster_role_respects_the_minimum(AdminRole minRole, bool expected)
|
||||
{
|
||||
var user = User(Cluster("WARSAW", AdminRole.ConfigEditor));
|
||||
user.HasClusterRole("WARSAW", minRole).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Has_cluster_role_is_false_without_any_grant()
|
||||
=> User().HasClusterRole("WARSAW", AdminRole.ConfigViewer).ShouldBeFalse();
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentCsvImporterTests
|
||||
{
|
||||
// Admin-012: header no longer includes EquipmentId — that field is system-derived.
|
||||
private const string Header =
|
||||
"# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName";
|
||||
|
||||
[Fact]
|
||||
public void EmptyFile_Throws()
|
||||
{
|
||||
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingVersionMarker_Throws()
|
||||
{
|
||||
var csv = "ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("# OtOpcUaCsv v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRequiredColumn_Throws()
|
||||
{
|
||||
var csv = "# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,mc,sap,Name1,area,line";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("EquipmentUuid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownColumn_Throws()
|
||||
{
|
||||
var csv = Header + ",WeirdColumn\nz1,mc,sap,uu,Name1,area,line,value";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("WeirdColumn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateColumn_Throws()
|
||||
{
|
||||
var csv = "# OtOpcUaCsv v1\n" +
|
||||
"ZTag,ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,z1,mc,sap,uu,Name,area,line";
|
||||
|
||||
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidSingleRow_RoundTrips()
|
||||
{
|
||||
var csv = Header + "\nz-001,MC-1,SAP-1,uuid-1,Oven-A,Warsaw,Line-1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows.ShouldBeEmpty();
|
||||
var row = result.AcceptedRows[0];
|
||||
row.ZTag.ShouldBe("z-001");
|
||||
row.MachineCode.ShouldBe("MC-1");
|
||||
row.Name.ShouldBe("Oven-A");
|
||||
row.UnsLineName.ShouldBe("Line-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionalColumns_Populated_WhenPresent()
|
||||
{
|
||||
var csv = "# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
|
||||
"z-1,MC,SAP,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(1);
|
||||
var row = result.AcceptedRows[0];
|
||||
row.Manufacturer.ShouldBe("Siemens");
|
||||
row.Model.ShouldBe("S7-1500");
|
||||
row.SerialNumber.ShouldBe("SN123");
|
||||
row.YearOfConstruction.ShouldBe("2023");
|
||||
row.ManufacturerUri.ShouldBe("https://siemens.example");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankRequiredField_Rejects_Row()
|
||||
{
|
||||
var csv = Header + "\nz-1,MC,SAP,uuid,,Warsaw,Line1"; // Name blank
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
result.AcceptedRows.ShouldBeEmpty();
|
||||
result.RejectedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows[0].Reason.ShouldContain("Name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateZTag_Rejects_SecondRow()
|
||||
{
|
||||
var csv = Header +
|
||||
"\nz-1,MC1,SAP1,u1,N1,A,L1" +
|
||||
"\nz-1,MC2,SAP2,u2,N2,A,L1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows[0].Reason.ShouldContain("Duplicate ZTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuotedField_With_CommaAndQuote_Parses_Correctly()
|
||||
{
|
||||
// RFC 4180: "" inside a quoted field is an escaped quote.
|
||||
var csv = Header +
|
||||
"\n\"z-1\",\"MC\",\"SAP,with,commas\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\"";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(1);
|
||||
result.AcceptedRows[0].SAPID.ShouldBe("SAP,with,commas");
|
||||
result.AcceptedRows[0].Name.ShouldBe("Oven \"Ultra\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MismatchedColumnCount_Rejects_Row()
|
||||
{
|
||||
var csv = Header + "\nz-1,MC,SAP,uuid,Name,Warsaw"; // missing UnsLineName cell
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
result.AcceptedRows.ShouldBeEmpty();
|
||||
result.RejectedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows[0].Reason.ShouldContain("Column count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankLines_BetweenRows_AreIgnored()
|
||||
{
|
||||
var csv = Header +
|
||||
"\nz-1,MC,SAP,u1,N1,A,L1" +
|
||||
"\n" +
|
||||
"\nz-2,MC,SAP,u2,N2,A,L1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(2);
|
||||
result.RejectedRows.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_Constants_Match_Decision_117_and_139()
|
||||
{
|
||||
// Admin-012: EquipmentId is intentionally absent — derived from EquipmentUuid at finalise time.
|
||||
EquipmentCsvImporter.RequiredColumns.ShouldBe(
|
||||
["ZTag", "MachineCode", "SAPID", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
|
||||
|
||||
EquipmentCsvImporter.OptionalColumns.ShouldBe(
|
||||
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri"]);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Admin-012 — <c>admin-ui.md</c> ("Equipment CSV import", revised after
|
||||
/// adversarial review finding #4) requires no <c>EquipmentId</c> column: it is
|
||||
/// system-derived (<c>'EQ-' + first 12 hex chars of EquipmentUuid</c>) and "never
|
||||
/// accepted from CSV imports". Operator-supplied EquipmentId would mint duplicate
|
||||
/// equipment identity on typos.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentCsvNoEquipmentIdColumnTests
|
||||
{
|
||||
[Fact]
|
||||
public void RequiredColumns_does_not_include_EquipmentId()
|
||||
{
|
||||
EquipmentCsvImporter.RequiredColumns
|
||||
.ShouldNotContain("EquipmentId",
|
||||
customMessage: "Admin-012: admin-ui.md forbids an EquipmentId column on the CSV import — it is system-derived from EquipmentUuid.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptionalColumns_does_not_include_EquipmentId()
|
||||
{
|
||||
EquipmentCsvImporter.OptionalColumns
|
||||
.ShouldNotContain("EquipmentId",
|
||||
customMessage: "Admin-012: EquipmentId must not be an optional column either — it is never accepted from the CSV.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EquipmentCsvRow_has_no_EquipmentId_property()
|
||||
{
|
||||
// The CSV row shape mirrors the accepted columns. Keeping EquipmentId on the
|
||||
// row would invite the same misuse — drop it so the type system prevents
|
||||
// accidental population from a future column.
|
||||
var prop = typeof(EquipmentCsvRow).GetProperty("EquipmentId");
|
||||
prop.ShouldBeNull("Admin-012: EquipmentCsvRow must not expose an EquipmentId — the value is derived at finalise time.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Header_with_EquipmentId_column_is_rejected_as_unknown()
|
||||
{
|
||||
// After the fix, EquipmentId is an unknown column — the header validator must
|
||||
// refuse it like any other unrecognised column so operators get an explicit
|
||||
// error rather than silently importing the value.
|
||||
const string csv =
|
||||
"# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1";
|
||||
|
||||
var ex = Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
ex.Message.ShouldContain("EquipmentId",
|
||||
customMessage: "Importer must reject CSVs that still carry the (now disallowed) EquipmentId column.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Valid_csv_without_EquipmentId_is_accepted()
|
||||
{
|
||||
// The canonical header should now omit EquipmentId.
|
||||
const string csv =
|
||||
"# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z-1,MC,SAP,11111111-2222-3333-4444-555555555555,Oven,Warsaw,Line1";
|
||||
|
||||
var result = EquipmentCsvImporter.Parse(csv);
|
||||
result.AcceptedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows.ShouldBeEmpty();
|
||||
result.AcceptedRows[0].ZTag.ShouldBe("z-1");
|
||||
result.AcceptedRows[0].EquipmentUuid.ShouldBe("11111111-2222-3333-4444-555555555555");
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly EquipmentImportBatchService _svc;
|
||||
|
||||
public EquipmentImportBatchServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"import-batch-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
_svc = new EquipmentImportBatchService(_db);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
// Unique SAPID per row — FinaliseBatch reserves ZTag + SAPID via filtered-unique index, so
|
||||
// two rows sharing a SAPID under different EquipmentUuids collide as intended.
|
||||
// Admin-012: no EquipmentId on the CSV row — it is derived from EquipmentUuid at stage/finalise.
|
||||
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
|
||||
{
|
||||
ZTag = zTag,
|
||||
MachineCode = "mc",
|
||||
SAPID = $"sap-{zTag}",
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = name,
|
||||
UnsAreaName = "area",
|
||||
UnsLineName = "line",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task CreateBatch_PopulatesId_AndTimestamp()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
|
||||
batch.Id.ShouldNotBe(Guid.Empty);
|
||||
batch.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
|
||||
batch.RowsStaged.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StageRows_AcceptedAndRejected_AllPersist()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
|
||||
await _svc.StageRowsAsync(batch.Id,
|
||||
acceptedRows: [Row("z-1"), Row("z-2")],
|
||||
rejectedRows: [new EquipmentCsvRowError(LineNumber: 5, Reason: "duplicate ZTag")],
|
||||
CancellationToken.None);
|
||||
|
||||
var reloaded = await _db.EquipmentImportBatches.Include(b => b.Rows).FirstAsync(b => b.Id == batch.Id);
|
||||
reloaded.RowsStaged.ShouldBe(3);
|
||||
reloaded.RowsAccepted.ShouldBe(2);
|
||||
reloaded.RowsRejected.ShouldBe(1);
|
||||
reloaded.Rows.Count.ShouldBe(3);
|
||||
reloaded.Rows.Count(r => r.IsAccepted).ShouldBe(2);
|
||||
reloaded.Rows.Single(r => !r.IsAccepted).RejectReason.ShouldBe("duplicate ZTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropBatch_RemovesBatch_AndCascades_Rows()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
|
||||
|
||||
await _svc.DropBatchAsync(batch.Id, CancellationToken.None);
|
||||
|
||||
(await _db.EquipmentImportBatches.AnyAsync(b => b.Id == batch.Id)).ShouldBeFalse();
|
||||
(await _db.EquipmentImportRows.AnyAsync(r => r.BatchId == batch.Id)).ShouldBeFalse("cascaded delete clears rows");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropBatch_AfterFinalise_Throws()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch.Id, generationId: 1, driverInstanceIdForRows: "drv-1", unsLineIdForRows: "line-1", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
|
||||
() => _svc.DropBatchAsync(batch.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finalise_AcceptedRows_BecomeEquipment()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id,
|
||||
[Row("z-1", name: "alpha"), Row("z-2", name: "beta")],
|
||||
rejectedRows: [new EquipmentCsvRowError(1, "rejected")],
|
||||
CancellationToken.None);
|
||||
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 5, "drv-modbus", "line-warsaw", CancellationToken.None);
|
||||
|
||||
var equipment = await _db.Equipment.Where(e => e.GenerationId == 5).ToListAsync();
|
||||
equipment.Count.ShouldBe(2);
|
||||
equipment.Select(e => e.Name).ShouldBe(["alpha", "beta"], ignoreOrder: true);
|
||||
equipment.All(e => e.DriverInstanceId == "drv-modbus").ShouldBeTrue();
|
||||
equipment.All(e => e.UnsLineId == "line-warsaw").ShouldBeTrue();
|
||||
|
||||
var reloaded = await _db.EquipmentImportBatches.FirstAsync(b => b.Id == batch.Id);
|
||||
reloaded.FinalisedAtUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finalise_Twice_Throws()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
|
||||
() => _svc.FinaliseBatchAsync(batch.Id, 2, "drv", "line", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finalise_MissingBatch_Throws()
|
||||
{
|
||||
await Should.ThrowAsync<ImportBatchNotFoundException>(
|
||||
() => _svc.FinaliseBatchAsync(Guid.NewGuid(), 1, "drv", "line", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stage_After_Finalise_Throws()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<ImportBatchAlreadyFinalisedException>(
|
||||
() => _svc.StageRowsAsync(batch.Id, [Row("z-2")], [], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByUser_FiltersByCreator_AndFinalised()
|
||||
{
|
||||
var a = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var b = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(a.Id, [Row("z-a")], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(a.Id, 1, "d", "l", CancellationToken.None);
|
||||
_ = b;
|
||||
|
||||
var aliceOpen = await _svc.ListByUserAsync("alice", includeFinalised: false, CancellationToken.None);
|
||||
aliceOpen.ShouldBeEmpty("alice's only batch is finalised");
|
||||
|
||||
var aliceAll = await _svc.ListByUserAsync("alice", includeFinalised: true, CancellationToken.None);
|
||||
aliceAll.Count.ShouldBe(1);
|
||||
|
||||
var bobOpen = await _svc.ListByUserAsync("bob", includeFinalised: false, CancellationToken.None);
|
||||
bobOpen.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DropBatch_Unknown_IsNoOp()
|
||||
{
|
||||
await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
// no throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_Creates_ExternalIdReservations_ForZTagAndSAPID()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch.Id, [Row("z-new-1")], [], CancellationToken.None);
|
||||
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var active = await _db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.ToListAsync();
|
||||
active.Count.ShouldBe(2);
|
||||
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag && r.Value == "z-new-1");
|
||||
active.ShouldContain(r => r.Kind == ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID && r.Value == "sap-z-new-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_SameEquipmentUuid_ReusesExistingReservation()
|
||||
{
|
||||
var batch1 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var sharedUuid = Guid.NewGuid();
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-shared", MachineCode = "mc", SAPID = "sap-shared",
|
||||
EquipmentUuid = sharedUuid.ToString(),
|
||||
Name = "eq-1", UnsAreaName = "a", UnsLineName = "l",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch1.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch1.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var countAfterFirst = _db.ExternalIdReservations.Count(r => r.ReleasedAt == null);
|
||||
|
||||
// Second finalise with same EquipmentUuid + same ZTag — should NOT create a duplicate.
|
||||
var batch2 = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
await _svc.StageRowsAsync(batch2.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch2.Id, 2, "drv", "line", CancellationToken.None);
|
||||
|
||||
_db.ExternalIdReservations.Count(r => r.ReleasedAt == null).ShouldBe(countAfterFirst);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_DifferentEquipmentUuid_SameZTag_Throws_Conflict()
|
||||
{
|
||||
var batchA = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var rowA = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-a", SAPID = "sap-a",
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "a", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchA.Id, [rowA], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batchA.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
var batchB = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None);
|
||||
var rowBUuid = Guid.NewGuid();
|
||||
var rowB = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
|
||||
EquipmentUuid = rowBUuid.ToString(),
|
||||
Name = "b", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batchB.Id, [rowB], [], CancellationToken.None);
|
||||
|
||||
var ex = await Should.ThrowAsync<ExternalIdReservationConflictException>(() =>
|
||||
_svc.FinaliseBatchAsync(batchB.Id, 2, "drv", "line", CancellationToken.None));
|
||||
ex.Message.ShouldContain("z-collide");
|
||||
|
||||
// Second finalise must have rolled back — no partial Equipment row for batch B (match by UUID).
|
||||
var equipmentB = await _db.Equipment.AsNoTracking()
|
||||
.Where(e => e.EquipmentUuid == rowBUuid)
|
||||
.ToListAsync();
|
||||
equipmentB.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinaliseBatch_EmptyZTagAndSAPID_SkipsReservation()
|
||||
{
|
||||
var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None);
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "", MachineCode = "mc", SAPID = "",
|
||||
EquipmentUuid = Guid.NewGuid().ToString(),
|
||||
Name = "nil", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
await _svc.StageRowsAsync(batch.Id, [row], [], CancellationToken.None);
|
||||
await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None);
|
||||
|
||||
_db.ExternalIdReservations.Count().ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── ApplyReservationPreCheck tests ──────────────────────────────────────────
|
||||
|
||||
/// <summary>No active reservations → the parse result passes through unchanged.</summary>
|
||||
[Fact]
|
||||
public async Task PreCheck_NoReservations_ReturnsUnchanged()
|
||||
{
|
||||
var input = new EquipmentCsvParseResult(
|
||||
AcceptedRows: [Row("z-1"), Row("z-2")],
|
||||
RejectedRows: []);
|
||||
|
||||
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(2);
|
||||
result.RejectedRows.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ZTag reserved by a DIFFERENT EquipmentUuid → row moves to rejected with a descriptive reason;
|
||||
/// SAPID of that same row is ignored since the row is already conflicted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PreCheck_ZTagConflict_MovesRowToRejected()
|
||||
{
|
||||
// Seed an active reservation for "z-taken" owned by a different UUID.
|
||||
var ownerUuid = Guid.NewGuid();
|
||||
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
|
||||
Value = "z-taken",
|
||||
EquipmentUuid = ownerUuid,
|
||||
ClusterId = "c1",
|
||||
FirstPublishedBy = "alice",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var importerUuid = Guid.NewGuid(); // different UUID — conflict
|
||||
var conflictRow = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok",
|
||||
EquipmentUuid = importerUuid.ToString(),
|
||||
Name = "x", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
var cleanRow = Row("z-clean");
|
||||
|
||||
var input = new EquipmentCsvParseResult(
|
||||
AcceptedRows: [conflictRow, cleanRow],
|
||||
RejectedRows: [new EquipmentCsvRowError(99, "pre-existing parser rejection")]);
|
||||
|
||||
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(1, "only the clean row remains accepted");
|
||||
result.AcceptedRows[0].ZTag.ShouldBe("z-clean");
|
||||
|
||||
result.RejectedRows.Count.ShouldBe(2, "pre-existing + the new conflict rejection");
|
||||
var conflictError = result.RejectedRows.Single(e => e.Reason.Contains("z-taken"));
|
||||
conflictError.Reason.ShouldContain(ownerUuid.ToString());
|
||||
conflictError.Reason.ShouldContain("ZTag");
|
||||
}
|
||||
|
||||
/// <summary>SAPID reserved by a different EquipmentUuid → row is rejected with a SAPID-specific reason.</summary>
|
||||
[Fact]
|
||||
public async Task PreCheck_SAPIDConflict_MovesRowToRejected()
|
||||
{
|
||||
var ownerUuid = Guid.NewGuid();
|
||||
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID,
|
||||
Value = "sap-taken",
|
||||
EquipmentUuid = ownerUuid,
|
||||
ClusterId = "c1",
|
||||
FirstPublishedBy = "alice",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var importerUuid = Guid.NewGuid();
|
||||
var conflictRow = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken",
|
||||
EquipmentUuid = importerUuid.ToString(),
|
||||
Name = "y", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
|
||||
var input = new EquipmentCsvParseResult(AcceptedRows: [conflictRow], RejectedRows: []);
|
||||
|
||||
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(0);
|
||||
result.RejectedRows.Count.ShouldBe(1);
|
||||
result.RejectedRows[0].Reason.ShouldContain("sap-taken");
|
||||
result.RejectedRows[0].Reason.ShouldContain("SAPID");
|
||||
result.RejectedRows[0].Reason.ShouldContain(ownerUuid.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reservation active for the SAME EquipmentUuid → row is NOT rejected (normal re-publish).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task PreCheck_SameEquipmentUuid_NotFlagged()
|
||||
{
|
||||
var sharedUuid = Guid.NewGuid();
|
||||
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
|
||||
Value = "z-mine",
|
||||
EquipmentUuid = sharedUuid,
|
||||
ClusterId = "c1",
|
||||
FirstPublishedBy = "alice",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-mine", MachineCode = "mc", SAPID = "sap-mine",
|
||||
EquipmentUuid = sharedUuid.ToString(), // same UUID
|
||||
Name = "z", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
|
||||
var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []);
|
||||
|
||||
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(1, "same UUID → not a conflict");
|
||||
result.RejectedRows.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A released reservation (ReleasedAt IS NOT NULL) does not block the import row.</summary>
|
||||
[Fact]
|
||||
public async Task PreCheck_ReleasedReservation_IsIgnored()
|
||||
{
|
||||
var oldOwner = Guid.NewGuid();
|
||||
_db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation
|
||||
{
|
||||
ReservationId = Guid.NewGuid(),
|
||||
Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag,
|
||||
Value = "z-released",
|
||||
EquipmentUuid = oldOwner,
|
||||
ClusterId = "c1",
|
||||
FirstPublishedBy = "alice",
|
||||
ReleasedAt = DateTime.UtcNow.AddDays(-1),
|
||||
ReleasedBy = "bob",
|
||||
ReleaseReason = "decommissioned",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var newImporterUuid = Guid.NewGuid();
|
||||
var row = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new",
|
||||
EquipmentUuid = newImporterUuid.ToString(),
|
||||
Name = "new", UnsAreaName = "ar", UnsLineName = "ln",
|
||||
};
|
||||
|
||||
var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []);
|
||||
|
||||
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
|
||||
|
||||
result.AcceptedRows.Count.ShouldBe(1, "released reservation is free to claim");
|
||||
result.RejectedRows.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>Empty accepted list short-circuits without hitting the DB.</summary>
|
||||
[Fact]
|
||||
public async Task PreCheck_EmptyInput_ReturnsUnchanged()
|
||||
{
|
||||
var input = new EquipmentCsvParseResult(
|
||||
AcceptedRows: [],
|
||||
RejectedRows: [new EquipmentCsvRowError(1, "already rejected")]);
|
||||
|
||||
var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None);
|
||||
|
||||
result.ShouldBeSameAs(input, "same instance when there is nothing to check");
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase 6.4 Stream B.5 five-identifier ranked search.
|
||||
/// Decision #117 identifiers: ZTag / MachineCode / SAPID / EquipmentId / EquipmentUuid.
|
||||
/// Scoring: exact match = 100, prefix = 50, fuzzy (opt-in) = 20.
|
||||
/// Tie-break: published generation outranks draft.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentSearchTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly EquipmentService _svc;
|
||||
|
||||
private const string ClusterId = "cluster-1";
|
||||
private const long DraftGenId = 1L;
|
||||
private const long PublishedGenId = 2L;
|
||||
|
||||
public EquipmentSearchTests()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"eq-search-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(opts);
|
||||
|
||||
// Seed two generations — draft + published — for the same cluster.
|
||||
_db.ConfigGenerations.AddRange(
|
||||
new ConfigGeneration
|
||||
{
|
||||
GenerationId = DraftGenId,
|
||||
ClusterId = ClusterId,
|
||||
Status = GenerationStatus.Draft,
|
||||
CreatedBy = "test",
|
||||
},
|
||||
new ConfigGeneration
|
||||
{
|
||||
GenerationId = PublishedGenId,
|
||||
ClusterId = ClusterId,
|
||||
Status = GenerationStatus.Published,
|
||||
CreatedBy = "test",
|
||||
PublishedAt = DateTime.UtcNow,
|
||||
PublishedBy = "test",
|
||||
});
|
||||
_db.SaveChanges();
|
||||
|
||||
_svc = new EquipmentService(_db);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private Equipment AddEquipment(
|
||||
long generationId,
|
||||
string name,
|
||||
string ztag,
|
||||
string machineCode = "MC",
|
||||
string sapid = "",
|
||||
Guid? uuid = null,
|
||||
string equipmentId = "")
|
||||
{
|
||||
var uu = uuid ?? Guid.NewGuid();
|
||||
var eq = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(),
|
||||
GenerationId = generationId,
|
||||
EquipmentId = string.IsNullOrEmpty(equipmentId) ? $"EQ-{uu:N}"[..14] : equipmentId,
|
||||
EquipmentUuid = uu,
|
||||
DriverInstanceId = "drv",
|
||||
UnsLineId = "line-1",
|
||||
Name = name,
|
||||
MachineCode = machineCode,
|
||||
ZTag = ztag,
|
||||
SAPID = string.IsNullOrEmpty(sapid) ? null : sapid,
|
||||
};
|
||||
_db.Equipment.Add(eq);
|
||||
_db.SaveChanges();
|
||||
return eq;
|
||||
}
|
||||
|
||||
// ── Exact-match tests (score 100) ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ExactMatch_ZTag_Returns_Score100()
|
||||
{
|
||||
AddEquipment(DraftGenId, "Oven-A", ztag: "z-001");
|
||||
|
||||
var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(1);
|
||||
hits[0].Score.ShouldBe(100);
|
||||
hits[0].MatchedField.ShouldBe("ZTag");
|
||||
hits[0].Equipment.Name.ShouldBe("Oven-A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactMatch_IsCaseInsensitive()
|
||||
{
|
||||
AddEquipment(DraftGenId, "Welder-1", ztag: "Z-ABC");
|
||||
|
||||
var hits = await _svc.SearchAsync("z-abc", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(1);
|
||||
hits[0].Score.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactMatch_MachineCode_Returns_Score100()
|
||||
{
|
||||
AddEquipment(DraftGenId, "Wrapper", ztag: "z-2", machineCode: "MC-42");
|
||||
|
||||
var hits = await _svc.SearchAsync("MC-42", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(1);
|
||||
hits[0].Score.ShouldBe(100);
|
||||
hits[0].MatchedField.ShouldBe("MachineCode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactMatch_SAPID_Returns_Score100()
|
||||
{
|
||||
AddEquipment(DraftGenId, "Conveyor", ztag: "z-3", sapid: "SAP-999");
|
||||
|
||||
var hits = await _svc.SearchAsync("SAP-999", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(1);
|
||||
hits[0].Score.ShouldBe(100);
|
||||
hits[0].MatchedField.ShouldBe("SAPID");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactMatch_EquipmentUuid_Returns_Score100()
|
||||
{
|
||||
var uu = Guid.NewGuid();
|
||||
AddEquipment(DraftGenId, "Robot-A", ztag: "z-4", uuid: uu);
|
||||
|
||||
var hits = await _svc.SearchAsync(uu.ToString(), ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(1);
|
||||
hits[0].Score.ShouldBe(100);
|
||||
hits[0].MatchedField.ShouldBe("EquipmentUuid");
|
||||
}
|
||||
|
||||
// ── Prefix-match tests (score 50) ────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PrefixMatch_ZTag_Returns_Score50()
|
||||
{
|
||||
AddEquipment(DraftGenId, "Press-1", ztag: "z-alpha-001");
|
||||
|
||||
var hits = await _svc.SearchAsync("z-alpha", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(1);
|
||||
hits[0].Score.ShouldBe(50);
|
||||
hits[0].MatchedField.ShouldBe("ZTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactOutranks_Prefix_InResults()
|
||||
{
|
||||
// exact: z-001 == "z-001" → score 100
|
||||
// prefix: z-001x startsWith "z-001" → score 50
|
||||
AddEquipment(DraftGenId, "Exact-Hit", ztag: "z-001");
|
||||
AddEquipment(DraftGenId, "Prefix-Hit", ztag: "z-001x");
|
||||
|
||||
var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(2);
|
||||
hits[0].Score.ShouldBe(100);
|
||||
hits[0].Equipment.Name.ShouldBe("Exact-Hit");
|
||||
hits[1].Score.ShouldBe(50);
|
||||
hits[1].Equipment.Name.ShouldBe("Prefix-Hit");
|
||||
}
|
||||
|
||||
// ── Fuzzy-match tests (score 20, opt-in) ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task FuzzyMatch_Disabled_DoesNotReturn_SubstringOnly_Hit()
|
||||
{
|
||||
AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-INFIX-suffix");
|
||||
|
||||
var hits = await _svc.SearchAsync("INFIX", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: false);
|
||||
|
||||
hits.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FuzzyMatch_Enabled_Returns_Score20()
|
||||
{
|
||||
AddEquipment(DraftGenId, "SubstrEq", ztag: "prefix-infix-suffix");
|
||||
|
||||
var hits = await _svc.SearchAsync("infix", ClusterId, TestContext.Current.CancellationToken, allowFuzzy: true);
|
||||
|
||||
hits.Count.ShouldBe(1);
|
||||
hits[0].Score.ShouldBe(20);
|
||||
hits[0].MatchedField.ShouldBe("ZTag");
|
||||
}
|
||||
|
||||
// ── Tie-break: published outranks draft ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PublishedGeneration_Outranks_Draft_ForEqualScore()
|
||||
{
|
||||
// Same ZTag prefix "mc-" in both draft + published generation.
|
||||
AddEquipment(DraftGenId, "Draft-Eq", ztag: "mc-001");
|
||||
AddEquipment(PublishedGenId, "Published-Eq", ztag: "mc-002");
|
||||
|
||||
// Both hit prefix match on "mc-" (score 50).
|
||||
var hits = await _svc.SearchAsync("mc-", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.Count.ShouldBe(2);
|
||||
// Published generation should come first.
|
||||
hits[0].IsPublished.ShouldBeTrue();
|
||||
hits[1].IsPublished.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── Empty / no-match ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyQuery_Returns_EmptyList()
|
||||
{
|
||||
AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999");
|
||||
|
||||
var hits = await _svc.SearchAsync(" ", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoMatch_Returns_EmptyList()
|
||||
{
|
||||
AddEquipment(DraftGenId, "Irrelevant", ztag: "z-999");
|
||||
|
||||
var hits = await _svc.SearchAsync("xyzzy-unknown", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ── Cross-cluster isolation ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Equipment_In_DifferentCluster_NotReturned()
|
||||
{
|
||||
// Seed a generation in a different cluster.
|
||||
_db.ConfigGenerations.Add(new ConfigGeneration
|
||||
{
|
||||
GenerationId = 99L,
|
||||
ClusterId = "cluster-other",
|
||||
Status = GenerationStatus.Draft,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
_db.SaveChanges();
|
||||
AddEquipment(99L, "OtherEq", ztag: "z-001");
|
||||
|
||||
var hits = await _svc.SearchAsync("z-001", ClusterId, TestContext.Current.CancellationToken);
|
||||
|
||||
hits.ShouldBeEmpty("equipment from another cluster must be invisible");
|
||||
}
|
||||
|
||||
// ── MaxResults cap ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MaxResults_Limits_Output()
|
||||
{
|
||||
for (var i = 0; i < 10; i++)
|
||||
AddEquipment(DraftGenId, $"Eq-{i}", ztag: $"zprefix-{i:D3}");
|
||||
|
||||
var hits = await _svc.SearchAsync("zprefix-", ClusterId, TestContext.Current.CancellationToken, maxResults: 3);
|
||||
|
||||
hits.Count.ShouldBe(3);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Admin-011 — <see cref="FleetStatusPoller"/> kept three plain
|
||||
/// <c>Dictionary<,></c> caches that were enumerated/mutated from the steady-state
|
||||
/// poll loop and cleared from <c>ResetCache()</c> with no synchronization. A concurrent
|
||||
/// <c>ResetCache()</c> during a poll iteration could throw
|
||||
/// <see cref="InvalidOperationException"/> or corrupt the dictionary. The fix swaps the
|
||||
/// caches for <see cref="ConcurrentDictionary{TKey,TValue}"/> so reset + concurrent
|
||||
/// reads/writes are safe by construction.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FleetStatusPollerConcurrencyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Cache_fields_are_thread_safe_collections()
|
||||
{
|
||||
// The fix uses ConcurrentDictionary; that makes ResetCache() and concurrent
|
||||
// poll-tick mutations safe by construction. Guard the structural choice with
|
||||
// reflection so a future refactor cannot silently revert to plain Dictionary
|
||||
// without flipping this guardrail.
|
||||
var fields = typeof(FleetStatusPoller)
|
||||
.GetFields(BindingFlags.NonPublic | BindingFlags.Instance)
|
||||
.Where(f => f.Name is "_last" or "_lastRole" or "_lastResilience")
|
||||
.ToList();
|
||||
|
||||
fields.Count.ShouldBe(3, "expected the three cache fields _last/_lastRole/_lastResilience to exist");
|
||||
|
||||
foreach (var f in fields)
|
||||
{
|
||||
var type = f.FieldType;
|
||||
type.IsGenericType.ShouldBeTrue($"{f.Name} should be a generic concurrent collection");
|
||||
type.GetGenericTypeDefinition().ShouldBe(
|
||||
typeof(ConcurrentDictionary<,>),
|
||||
customMessage: $"{f.Name} must be a ConcurrentDictionary<,> so concurrent ResetCache()/poll calls are safe — plain Dictionary regressed Admin-011.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetCache_is_safe_to_call_concurrently_with_cache_mutations()
|
||||
{
|
||||
// Stress test — hammer the cache with mutate/clear concurrently. With plain
|
||||
// Dictionary this throws InvalidOperationException ("Collection was modified")
|
||||
// or corrupts internal state. With ConcurrentDictionary it must complete cleanly.
|
||||
var poller = BuildPollerForReflectionTest();
|
||||
|
||||
var lastField = typeof(FleetStatusPoller).GetField("_last", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var cache = lastField.GetValue(poller)!;
|
||||
var cacheType = cache.GetType();
|
||||
var indexer = cacheType.GetProperty("Item")!;
|
||||
|
||||
var keyType = cacheType.GetGenericArguments()[0]; // string
|
||||
var valueType = cacheType.GetGenericArguments()[1]; // NodeStateSnapshot record-struct
|
||||
var defaultSnapshot = Activator.CreateInstance(valueType)!;
|
||||
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
|
||||
var writer = Task.Run(() =>
|
||||
{
|
||||
var i = 0;
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
indexer.SetValue(cache, defaultSnapshot, new object[] { $"node-{i++ % 64}" });
|
||||
}
|
||||
});
|
||||
var resetter = Task.Run(() =>
|
||||
{
|
||||
var method = typeof(FleetStatusPoller).GetMethod("ResetCache", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
method.Invoke(poller, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Should not throw — the whole point is that the two run concurrently safely.
|
||||
Should.NotThrow(() => Task.WaitAll([writer, resetter]));
|
||||
}
|
||||
|
||||
private static FleetStatusPoller BuildPollerForReflectionTest()
|
||||
{
|
||||
// Pass null-style stubs — the poller constructor doesn't touch them and we
|
||||
// never call ExecuteAsync/PollOnceAsync here (those need a real DB context).
|
||||
// We only exercise ResetCache + cache mutation by reflection.
|
||||
var scopeFactory = new StubServiceScopeFactory();
|
||||
var fleetHub = new StubHubContext<FleetStatusHub>();
|
||||
var alertHub = new StubHubContext<AlertHub>();
|
||||
return new FleetStatusPoller(
|
||||
scopeFactory,
|
||||
fleetHub,
|
||||
alertHub,
|
||||
NullLogger<FleetStatusPoller>.Instance,
|
||||
new RedundancyMetrics());
|
||||
}
|
||||
|
||||
private sealed class StubServiceScopeFactory : IServiceScopeFactory
|
||||
{
|
||||
public IServiceScope CreateScope() => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private sealed class StubHubContext<THub> : IHubContext<THub> where THub : Hub
|
||||
{
|
||||
public IHubClients Clients => throw new NotImplementedException();
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class FleetStatusPollerTests : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "10.100.0.35,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
private readonly string _databaseName = $"OtOpcUaPollerTest_{Guid.NewGuid():N}";
|
||||
private readonly string _connectionString;
|
||||
private readonly ServiceProvider _sp;
|
||||
|
||||
public FleetStatusPollerTests()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
_connectionString =
|
||||
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSignalR();
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
|
||||
_sp = services.BuildServiceProvider();
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sp.Dispose();
|
||||
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
|
||||
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString)
|
||||
{ InitialCatalog = "master" }.ConnectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
IF DB_ID(N'{_databaseName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{_databaseName}];
|
||||
END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poller_detects_new_apply_state_and_pushes_to_fleet_hub()
|
||||
{
|
||||
// Seed a cluster + node + credential + generation + apply state.
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "p-1", Name = "Poll test", Enterprise = "zb", Site = "dev",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = "p-1-a", ClusterId = "p-1", RedundancyRole = RedundancyRole.Primary,
|
||||
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
|
||||
ApplicationUri = "urn:p1:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = "p-1", Status = GenerationStatus.Published, CreatedBy = "t",
|
||||
PublishedBy = "t", PublishedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.ConfigGenerations.Add(gen);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
|
||||
{
|
||||
NodeId = "p-1-a", CurrentGenerationId = gen.GenerationId,
|
||||
LastAppliedStatus = NodeApplyStatus.Applied,
|
||||
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Recording hub contexts — capture what would be pushed to clients.
|
||||
var recorder = new RecordingHubClients();
|
||||
var fleetHub = new RecordingHubContext<FleetStatusHub>(recorder);
|
||||
var alertHub = new RecordingHubContext<AlertHub>(new RecordingHubClients());
|
||||
|
||||
var poller = new FleetStatusPoller(
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
|
||||
var match = recorder.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "NodeStateChanged" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is NodeStateChangedMessage msg &&
|
||||
msg.NodeId == "p-1-a");
|
||||
match.ShouldNotBeNull("poller should have pushed a NodeStateChanged for p-1-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poller_raises_alert_on_transition_into_Failed()
|
||||
{
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "p-2", Name = "Fail test", Enterprise = "zb", Site = "dev",
|
||||
NodeCount = 1, RedundancyMode = RedundancyMode.None, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = "p-2-a", ClusterId = "p-2", RedundancyRole = RedundancyRole.Primary,
|
||||
Host = "localhost", OpcUaPort = 4840, DashboardPort = 5001,
|
||||
ApplicationUri = "urn:p2:test", ServiceLevelBase = 200, Enabled = true, CreatedBy = "t",
|
||||
});
|
||||
db.ClusterNodeGenerationStates.Add(new ClusterNodeGenerationState
|
||||
{
|
||||
NodeId = "p-2-a",
|
||||
LastAppliedStatus = NodeApplyStatus.Failed,
|
||||
LastAppliedError = "simulated",
|
||||
LastAppliedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var alerts = new RecordingHubClients();
|
||||
var alertHub = new RecordingHubContext<AlertHub>(alerts);
|
||||
var fleetHub = new RecordingHubContext<FleetStatusHub>(new RecordingHubClients());
|
||||
|
||||
var poller = new FleetStatusPoller(
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
|
||||
var alertMatch = alerts.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "AlertRaised" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is AlertMessage alert && alert.NodeId == "p-2-a" && alert.Severity == "error");
|
||||
alertMatch.ShouldNotBeNull("poller should have raised AlertRaised for p-2-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Poller_pushes_ResilienceStatusChanged_on_delta()
|
||||
{
|
||||
// Phase 6.1 Stream E.2 — DriverInstanceResilienceStatus row changes should surface
|
||||
// on the fleet hub so /hosts updates without waiting for the 10s poll.
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
db.DriverInstanceResilienceStatuses.Add(new DriverInstanceResilienceStatus
|
||||
{
|
||||
DriverInstanceId = "drv-1", HostName = "plc.example.com",
|
||||
ConsecutiveFailures = 2, CurrentBulkheadDepth = 1,
|
||||
LastSampledUtc = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var recorder = new RecordingHubClients();
|
||||
var fleetHub = new RecordingHubContext<FleetStatusHub>(recorder);
|
||||
var alertHub = new RecordingHubContext<AlertHub>(new RecordingHubClients());
|
||||
|
||||
var poller = new FleetStatusPoller(
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
fleetHub, alertHub, NullLogger<FleetStatusPoller>.Instance, new RedundancyMetrics());
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
|
||||
var match = recorder.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "ResilienceStatusChanged" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is ResilienceStatusChangedMessage r &&
|
||||
r.DriverInstanceId == "drv-1" && r.HostName == "plc.example.com");
|
||||
match.ShouldNotBeNull("poller should have pushed ResilienceStatusChanged on first observation");
|
||||
|
||||
// Same snapshot on the next tick — should NOT push again (delta-only push).
|
||||
recorder.SentMessages.Clear();
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
recorder.SentMessages.Any(m => m.Method == "ResilienceStatusChanged")
|
||||
.ShouldBeFalse("unchanged snapshot must not fire another push");
|
||||
|
||||
// Mutate the row — delta should fire again.
|
||||
using (var scope = _sp.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var row = await db.DriverInstanceResilienceStatuses.SingleAsync();
|
||||
row.ConsecutiveFailures = 5;
|
||||
row.LastCircuitBreakerOpenUtc = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await poller.PollOnceAsync(CancellationToken.None);
|
||||
var mutatedMatch = recorder.SentMessages.FirstOrDefault(m =>
|
||||
m.Method == "ResilienceStatusChanged" &&
|
||||
m.Args.Length > 0 &&
|
||||
m.Args[0] is ResilienceStatusChangedMessage r2 && r2.ConsecutiveFailures == 5);
|
||||
mutatedMatch.ShouldNotBeNull("mutated row should produce a second ResilienceStatusChanged");
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDriverDetailServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_returns_null_for_unknown_instance()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
(await svc.GetAsync("missing", CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_returns_null_for_non_focas_driver_type()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-modbus", "ModbusTcp", "{}"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
(await svc.GetAsync("drv-modbus", CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_parses_devices_tags_and_alarm_projection()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", """
|
||||
{
|
||||
"Devices": [
|
||||
{ "HostAddress": "focas://10.20.30.40:8193", "Series": "ThirtyOne_i" }
|
||||
],
|
||||
"Tags": [
|
||||
{ "Name": "Mode", "DeviceHostAddress": "focas://10.20.30.40:8193",
|
||||
"Address": "PARAM:3402", "DataType": "Int32", "Writable": false }
|
||||
],
|
||||
"AlarmProjection": { "Enabled": true, "PollInterval": "00:00:05" },
|
||||
"HandleRecycle": { "Enabled": true, "Interval": "01:00:00" }
|
||||
}
|
||||
"""));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.ParseError.ShouldBeNull();
|
||||
detail.Config.ShouldNotBeNull();
|
||||
detail.Config.Devices!.Single().HostAddress.ShouldBe("focas://10.20.30.40:8193");
|
||||
detail.Config.Devices!.Single().Series.ShouldBe("ThirtyOne_i");
|
||||
detail.Config.Tags!.Single().Name.ShouldBe("Mode");
|
||||
detail.Config.AlarmProjection!.Enabled.ShouldBeTrue();
|
||||
detail.Config.HandleRecycle!.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_surfaces_parse_error_for_malformed_json()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-bad", "Focas", "{ not-valid-json"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-bad", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.ParseError.ShouldNotBeNull();
|
||||
detail.Config.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_joins_host_status_rows_for_the_instance()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", "{}"));
|
||||
ctx.DriverHostStatuses.Add(new DriverHostStatus
|
||||
{
|
||||
NodeId = "node-A",
|
||||
DriverInstanceId = "drv-focas",
|
||||
HostName = "focas://10.0.0.1:8193",
|
||||
State = DriverHostState.Running,
|
||||
StateChangedUtc = DateTime.UtcNow.AddMinutes(-5),
|
||||
LastSeenUtc = DateTime.UtcNow.AddSeconds(-3),
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.HostStatuses.Count.ShouldBe(1);
|
||||
detail.HostStatuses[0].HostName.ShouldBe("focas://10.0.0.1:8193");
|
||||
detail.HostStatuses[0].State.ShouldBe("Running");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_picks_latest_generation_when_multiple_rows_exist()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", "{\"Tags\":[]}", generationId: 1));
|
||||
ctx.DriverInstances.Add(NewInstance("drv-focas", "Focas", """{"Tags":[{"Name":"later"}]}""", generationId: 2));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var svc = new FocasDriverDetailService(ctx);
|
||||
var detail = await svc.GetAsync("drv-focas", CancellationToken.None);
|
||||
|
||||
detail.ShouldNotBeNull();
|
||||
detail.Config!.Tags!.Single().Name.ShouldBe("later");
|
||||
}
|
||||
|
||||
private static DriverInstance NewInstance(
|
||||
string driverInstanceId, string driverType, string driverConfigJson, long generationId = 1) => new()
|
||||
{
|
||||
GenerationId = generationId,
|
||||
DriverInstanceId = driverInstanceId,
|
||||
ClusterId = "cluster-1",
|
||||
NamespaceId = "ns-1",
|
||||
Name = driverInstanceId,
|
||||
DriverType = driverType,
|
||||
DriverConfig = driverConfigJson,
|
||||
};
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System.Reflection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic unit tests for the LDAP input-sanitization and DN-parsing helpers. Live LDAP
|
||||
/// bind against the GLAuth dev instance is covered by the admin-browser smoke path, not here,
|
||||
/// because unit runs must not depend on a running external service.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapAuthServiceTests
|
||||
{
|
||||
private static string EscapeLdapFilter(string input) =>
|
||||
(string)typeof(LdapAuthService)
|
||||
.GetMethod("EscapeLdapFilter", BindingFlags.NonPublic | BindingFlags.Static)!
|
||||
.Invoke(null, [input])!;
|
||||
|
||||
private static string ExtractFirstRdnValue(string dn) =>
|
||||
(string)typeof(LdapAuthService)
|
||||
.GetMethod("ExtractFirstRdnValue", BindingFlags.NonPublic | BindingFlags.Static)!
|
||||
.Invoke(null, [dn])!;
|
||||
|
||||
[Theory]
|
||||
[InlineData("alice", "alice")]
|
||||
[InlineData("a(b)c", "a\\28b\\29c")]
|
||||
[InlineData("wildcard*", "wildcard\\2a")]
|
||||
[InlineData("back\\slash", "back\\5cslash")]
|
||||
public void Escape_filter_replaces_control_chars(string input, string expected)
|
||||
{
|
||||
EscapeLdapFilter(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local", "ReadOnly")]
|
||||
[InlineData("cn=admin,dc=corp,dc=com", "admin")]
|
||||
[InlineData("ReadOnly", "ReadOnly")] // no '=' → pass through
|
||||
[InlineData("ou=OnlySegment", "OnlySegment")]
|
||||
public void Extract_first_RDN_strips_the_first_attribute_value(string dn, string expected)
|
||||
{
|
||||
ExtractFirstRdnValue(dn).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped when
|
||||
/// the port is unreachable so the test suite stays portable. Verifies the bind path —
|
||||
/// group/role resolution is covered deterministically by <see cref="RoleMapperTests"/>,
|
||||
/// <see cref="LdapAuthServiceTests"/>, and varies per directory (GLAuth, OpenLDAP, AD emit
|
||||
/// <c>memberOf</c> differently; the service has a DN-based fallback for the GLAuth case).
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class LdapLiveBindTests
|
||||
{
|
||||
private static bool GlauthReachable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync("localhost", 3893);
|
||||
return task.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static LdapAuthService NewService() => new(Options.Create(new LdapOptions
|
||||
{
|
||||
Server = "localhost",
|
||||
Port = 3893,
|
||||
UseTls = false,
|
||||
AllowInsecureLdap = true,
|
||||
SearchBase = "dc=lmxopcua,dc=local",
|
||||
ServiceAccountDn = "", // direct-bind: GLAuth's nameformat=cn + baseDN means user DN is cn={name},{baseDN}
|
||||
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
["WriteOperate"] = "ConfigEditor",
|
||||
["AlarmAck"] = "FleetAdmin",
|
||||
},
|
||||
}), NullLogger<LdapAuthService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_credentials_bind_successfully()
|
||||
{
|
||||
if (!GlauthReachable()) return;
|
||||
|
||||
var result = await NewService().AuthenticateAsync("readonly", "readonly123");
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Username.ShouldBe("readonly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_fails_bind()
|
||||
{
|
||||
if (!GlauthReachable()) return;
|
||||
|
||||
var result = await NewService().AuthenticateAsync("readonly", "wrong-pw");
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldContain("Invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_username_is_rejected_before_hitting_the_directory()
|
||||
{
|
||||
// Doesn't need GLAuth — pre-flight validation in the service.
|
||||
var result = await NewService().AuthenticateAsync("", "anything");
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldContain("required", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Modbus;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #145 Admin UI: smoke coverage for the ModbusOptionsEditor view model. The Blazor
|
||||
/// component itself is exercised in browser-runtime tests; this fixture pins the default
|
||||
/// values the form initialises to so a regression that flips an unedited row to a
|
||||
/// non-default value gets caught.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusOptionsViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void DriversTab_Serialized_Defaults_RoundTrip_Through_Factory()
|
||||
{
|
||||
// #147 — the form's SaveAsync serialises ModbusOptionsViewModel to the JSON DTO
|
||||
// shape ModbusDriverFactoryExtensions consumes. This test pins the round-trip:
|
||||
// unedited form → JSON → driver instance → options match defaults.
|
||||
var vm = new ModbusOptionsEditor.ModbusOptionsViewModel();
|
||||
var json = SerializeForRoundTrip(vm);
|
||||
|
||||
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-roundtrip", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
|
||||
opts.Host.ShouldBe(vm.Host);
|
||||
opts.Port.ShouldBe(vm.Port);
|
||||
opts.UnitId.ShouldBe(vm.UnitId);
|
||||
opts.Family.ShouldBe(vm.Family);
|
||||
opts.MelsecSubFamily.ShouldBe(vm.MelsecSubFamily);
|
||||
opts.KeepAlive.Enabled.ShouldBe(vm.KeepAliveEnabled);
|
||||
opts.MaxRegistersPerRead.ShouldBe((ushort)vm.MaxRegistersPerRead);
|
||||
opts.MaxCoilsPerRead.ShouldBe((ushort)vm.MaxCoilsPerRead);
|
||||
opts.MaxReadGap.ShouldBe((ushort)vm.MaxReadGap);
|
||||
opts.WriteOnChangeOnly.ShouldBe(vm.WriteOnChangeOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriversTab_Serializes_Edited_Values_Correctly()
|
||||
{
|
||||
// Sanity: changing a few fields in the view model produces a JSON the factory
|
||||
// accepts and the resulting driver carries the edited values.
|
||||
var vm = new ModbusOptionsEditor.ModbusOptionsViewModel
|
||||
{
|
||||
Host = "10.5.5.5",
|
||||
Port = 1502,
|
||||
UnitId = 7,
|
||||
Family = ModbusFamily.DL205,
|
||||
MaxReadGap = 12,
|
||||
WriteOnChangeOnly = true,
|
||||
};
|
||||
var json = SerializeForRoundTrip(vm);
|
||||
var driver = ModbusDriverFactoryExtensions.CreateInstance("modbus-edited", json);
|
||||
var opts = (ModbusDriverOptions)typeof(ModbusDriver)
|
||||
.GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(driver)!;
|
||||
|
||||
opts.Host.ShouldBe("10.5.5.5");
|
||||
opts.Port.ShouldBe(1502);
|
||||
opts.UnitId.ShouldBe((byte)7);
|
||||
opts.Family.ShouldBe(ModbusFamily.DL205);
|
||||
opts.MaxReadGap.ShouldBe((ushort)12);
|
||||
opts.WriteOnChangeOnly.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of DriversTab.razor's SerializeModbusOptions — kept here so the test
|
||||
/// doesn't have to reach through Blazor component plumbing to invoke it. If the
|
||||
/// component method signature drifts, update both.
|
||||
/// </summary>
|
||||
private static string SerializeForRoundTrip(ModbusOptionsEditor.ModbusOptionsViewModel m) =>
|
||||
System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
host = m.Host,
|
||||
port = m.Port,
|
||||
unitId = m.UnitId,
|
||||
family = m.Family.ToString(),
|
||||
melsecSubFamily = m.MelsecSubFamily.ToString(),
|
||||
keepAlive = new
|
||||
{
|
||||
enabled = m.KeepAliveEnabled,
|
||||
timeMs = m.KeepAliveTimeSec * 1000,
|
||||
intervalMs = m.KeepAliveIntervalSec * 1000,
|
||||
retryCount = m.KeepAliveRetryCount,
|
||||
},
|
||||
reconnect = new
|
||||
{
|
||||
initialDelayMs = m.ReconnectInitialDelayMs,
|
||||
maxDelayMs = m.ReconnectMaxDelayMs,
|
||||
backoffMultiplier = m.ReconnectBackoffMultiplier,
|
||||
},
|
||||
maxRegistersPerRead = m.MaxRegistersPerRead,
|
||||
maxRegistersPerWrite = m.MaxRegistersPerWrite,
|
||||
maxCoilsPerRead = m.MaxCoilsPerRead,
|
||||
maxReadGap = m.MaxReadGap,
|
||||
useFC15ForSingleCoilWrites = m.UseFC15ForSingleCoilWrites,
|
||||
useFC16ForSingleRegisterWrites = m.UseFC16ForSingleRegisterWrites,
|
||||
writeOnChangeOnly = m.WriteOnChangeOnly,
|
||||
tags = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void Defaults_Match_DriverOption_Defaults()
|
||||
{
|
||||
var vm = new ModbusOptionsEditor.ModbusOptionsViewModel();
|
||||
var driverDefault = new ModbusDriverOptions();
|
||||
|
||||
vm.Host.ShouldBe(driverDefault.Host);
|
||||
vm.Port.ShouldBe(driverDefault.Port);
|
||||
vm.UnitId.ShouldBe(driverDefault.UnitId);
|
||||
vm.Family.ShouldBe(driverDefault.Family);
|
||||
vm.MelsecSubFamily.ShouldBe(driverDefault.MelsecSubFamily);
|
||||
|
||||
vm.KeepAliveEnabled.ShouldBe(driverDefault.KeepAlive.Enabled);
|
||||
vm.KeepAliveTimeSec.ShouldBe((int)driverDefault.KeepAlive.Time.TotalSeconds);
|
||||
vm.KeepAliveIntervalSec.ShouldBe((int)driverDefault.KeepAlive.Interval.TotalSeconds);
|
||||
vm.KeepAliveRetryCount.ShouldBe(driverDefault.KeepAlive.RetryCount);
|
||||
|
||||
vm.ReconnectInitialDelayMs.ShouldBe((int)driverDefault.Reconnect.InitialDelay.TotalMilliseconds);
|
||||
vm.ReconnectMaxDelayMs.ShouldBe((int)driverDefault.Reconnect.MaxDelay.TotalMilliseconds);
|
||||
vm.ReconnectBackoffMultiplier.ShouldBe(driverDefault.Reconnect.BackoffMultiplier);
|
||||
|
||||
vm.MaxRegistersPerRead.ShouldBe(driverDefault.MaxRegistersPerRead);
|
||||
vm.MaxRegistersPerWrite.ShouldBe(driverDefault.MaxRegistersPerWrite);
|
||||
vm.MaxCoilsPerRead.ShouldBe(driverDefault.MaxCoilsPerRead);
|
||||
vm.MaxReadGap.ShouldBe(driverDefault.MaxReadGap);
|
||||
vm.UseFC15ForSingleCoilWrites.ShouldBe(driverDefault.UseFC15ForSingleCoilWrites);
|
||||
vm.UseFC16ForSingleRegisterWrites.ShouldBe(driverDefault.UseFC16ForSingleRegisterWrites);
|
||||
vm.WriteOnChangeOnly.ShouldBe(driverDefault.WriteOnChangeOnly);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for Admin-001 / Admin-002 — the router must enforce page-level
|
||||
/// <c>[Authorize]</c> attributes and the fallback authorization policy must keep every
|
||||
/// routable page (and mutating route) secure-by-default, while the login page, the
|
||||
/// <c>/auth/*</c> endpoints and static assets stay anonymously reachable.
|
||||
///
|
||||
/// These are HTTP-pipeline tests: they do not touch the config DB (the
|
||||
/// <see cref="OtOpcUaConfigDbContext"/> registration is lazy), so they run without the
|
||||
/// central SQL Server. The <see cref="FleetStatusPoller"/> hosted service is stripped out
|
||||
/// so the test host does not spin up a background DB poll loop.
|
||||
/// </summary>
|
||||
public sealed class PageAuthorizationTests : IClassFixture<PageAuthorizationTests.AdminAppFactory>
|
||||
{
|
||||
private readonly AdminAppFactory _factory;
|
||||
|
||||
public PageAuthorizationTests(AdminAppFactory factory) => _factory = factory;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="WebApplicationFactory{TEntryPoint}"/> over the Admin app's
|
||||
/// <c>Program</c>. Removes the background poller so the host starts clean without DB
|
||||
/// access and never follows redirects so the auth gate is observable as a 302.
|
||||
/// </summary>
|
||||
public sealed class AdminAppFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override IHost CreateHost(IHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var poller = services.SingleOrDefault(d =>
|
||||
d.ImplementationType?.Name == "FleetStatusPoller");
|
||||
if (poller is not null)
|
||||
services.Remove(poller);
|
||||
});
|
||||
return base.CreateHost(builder);
|
||||
}
|
||||
|
||||
public HttpClient CreateNonRedirectingClient() =>
|
||||
CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
}
|
||||
|
||||
public static readonly TheoryData<string> ProtectedRoutes = new()
|
||||
{
|
||||
"/", // Home — fleet overview
|
||||
"/fleet", // Fleet topology
|
||||
"/hosts", // Host status
|
||||
"/clusters", // Cluster list
|
||||
"/alarms/historian", // Historian diagnostics
|
||||
"/clusters/new", // NewCluster — MUTATING write surface (Admin-002)
|
||||
"/reservations", // CanPublish-gated page
|
||||
"/certificates", // FleetAdmin-gated page
|
||||
"/role-grants", // CanPublish-gated page
|
||||
"/account", // Authenticated-user page
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ProtectedRoutes))]
|
||||
public async Task Anonymous_request_to_a_protected_page_is_rejected(string route)
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
var response = await client.GetAsync(route);
|
||||
|
||||
// The cookie auth handler challenges an unauthenticated request with a 302 to
|
||||
// the configured LoginPath; a 401/403 is also an acceptable "not allowed in".
|
||||
if (response.StatusCode == HttpStatusCode.Redirect ||
|
||||
response.StatusCode == HttpStatusCode.Found)
|
||||
{
|
||||
response.Headers.Location!.OriginalString.ShouldContain("/login",
|
||||
Case.Insensitive, $"anonymous GET {route} must bounce to the login page");
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.OK,
|
||||
$"anonymous GET {route} must not be served — page-level [Authorize] / fallback policy must gate it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_post_to_a_mutating_route_does_not_reach_the_handler()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
// /clusters/new is the cluster-creation page; an anonymous POST to it must be
|
||||
// gated before any CreateAsync write path runs.
|
||||
var response = await client.PostAsync("/clusters/new",
|
||||
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
|
||||
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.OK,
|
||||
"anonymous POST to /clusters/new must not be served");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_page_is_anonymously_reachable()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
var response = await client.GetAsync("/login");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK,
|
||||
"the login page must stay anonymous or operators can never sign in");
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
body.ShouldContain("sign in", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Static_assets_remain_anonymously_reachable()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
// Vendored CSS served by the static-files middleware (not an endpoint) must not
|
||||
// be caught by the fallback authorization policy.
|
||||
foreach (var asset in new[] { "/app.css", "/theme.css" })
|
||||
{
|
||||
var response = await client.GetAsync(asset);
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.OK, HttpStatusCode.NotModified);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blazor_framework_script_remains_anonymously_reachable()
|
||||
{
|
||||
using var client = _factory.CreateNonRedirectingClient();
|
||||
|
||||
// The Blazor runtime must load before any auth interaction can happen.
|
||||
var response = await client.GetAsync("/_framework/blazor.web.js");
|
||||
|
||||
response.StatusCode.ShouldBeOneOf(HttpStatusCode.OK, HttpStatusCode.NotModified);
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
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.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PermissionProbeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Probe_Grants_When_ClusterLevelRow_CoversRequiredFlag()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, gen: 1, cluster: "c1",
|
||||
scopeKind: NodeAclScopeKind.Cluster, scopeId: null,
|
||||
group: "cn=operators", flags: NodePermissions.Browse | NodePermissions.Read);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var result = await svc.ProbeAsync(
|
||||
generationId: 1,
|
||||
ldapGroup: "cn=operators",
|
||||
scope: new NodeScope { ClusterId = "c1", NamespaceId = "ns-1", Kind = NodeHierarchyKind.Equipment },
|
||||
required: NodePermissions.Read,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Granted.ShouldBeTrue();
|
||||
result.Matches.Count.ShouldBe(1);
|
||||
result.Matches[0].LdapGroup.ShouldBe("cn=operators");
|
||||
result.Matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_Denies_When_NoGroupMatches()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var result = await svc.ProbeAsync(1, "cn=random-group",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.Read, CancellationToken.None);
|
||||
|
||||
result.Granted.ShouldBeFalse();
|
||||
result.Matches.ShouldBeEmpty();
|
||||
result.Effective.ShouldBe(NodePermissions.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_Denies_When_Effective_Missing_RequiredFlag()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Browse | NodePermissions.Read);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var result = await svc.ProbeAsync(1, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
required: NodePermissions.WriteOperate,
|
||||
CancellationToken.None);
|
||||
|
||||
result.Granted.ShouldBeFalse();
|
||||
result.Effective.ShouldBe(NodePermissions.Browse | NodePermissions.Read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_Ignores_Rows_From_OtherClusters()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, 1, "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||
SeedAcl(ctx, 1, "c2", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var c1Result = await svc.ProbeAsync(1, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.WriteOperate, CancellationToken.None);
|
||||
|
||||
c1Result.Granted.ShouldBeFalse("c2's WriteOperate grant must NOT leak into c1's probe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_UsesOnlyRows_From_Specified_Generation()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
SeedAcl(ctx, gen: 1, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.Read);
|
||||
SeedAcl(ctx, gen: 2, cluster: "c1", NodeAclScopeKind.Cluster, null, "cn=operators", NodePermissions.WriteOperate);
|
||||
var svc = new PermissionProbeService(ctx);
|
||||
|
||||
var gen1 = await svc.ProbeAsync(1, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.WriteOperate, CancellationToken.None);
|
||||
var gen2 = await svc.ProbeAsync(2, "cn=operators",
|
||||
new NodeScope { ClusterId = "c1", Kind = NodeHierarchyKind.Equipment },
|
||||
NodePermissions.WriteOperate, CancellationToken.None);
|
||||
|
||||
gen1.Granted.ShouldBeFalse();
|
||||
gen2.Granted.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static void SeedAcl(
|
||||
OtOpcUaConfigDbContext ctx, long gen, string cluster,
|
||||
NodeAclScopeKind scopeKind, string? scopeId, string group, NodePermissions flags)
|
||||
{
|
||||
ctx.NodeAcls.Add(new NodeAcl
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}"[..16],
|
||||
GenerationId = gen,
|
||||
ClusterId = cluster,
|
||||
LdapGroup = group,
|
||||
ScopeKind = scopeKind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = flags,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-side services shipped in Phase 7 Stream F — draft CRUD for scripts + virtual
|
||||
/// tags + scripted alarms, the pre-publish test harness, and the historian
|
||||
/// diagnostics façade.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ServicesTests
|
||||
{
|
||||
private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "")
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"phase7-{test}-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_AddAsync_generates_logical_id_and_hash()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
|
||||
var s = await svc.AddAsync(5, "line-rate", "return ctx.GetTag(\"a\").Value;", default);
|
||||
|
||||
s.ScriptId.ShouldStartWith("scr-");
|
||||
s.GenerationId.ShouldBe(5);
|
||||
s.SourceHash.Length.ShouldBe(64);
|
||||
(await svc.ListAsync(5, default)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_UpdateAsync_recomputes_hash_on_source_change()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||
var hashBefore = s.SourceHash;
|
||||
|
||||
var updated = await svc.UpdateAsync(5, s.ScriptId, "s", "return 2;", default);
|
||||
updated.SourceHash.ShouldNotBe(hashBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_UpdateAsync_same_source_same_hash()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||
var updated = await svc.UpdateAsync(5, s.ScriptId, "renamed", "return 1;", default);
|
||||
|
||||
updated.SourceHash.ShouldBe(s.SourceHash, "source unchanged → hash unchanged → compile cache hit preserved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_DeleteAsync_is_idempotent()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
|
||||
await Should.NotThrowAsync(() => svc.DeleteAsync(5, "nonexistent", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VirtualTagService_round_trips_trigger_flags()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new VirtualTagService(db);
|
||||
|
||||
var v = await svc.AddAsync(7, "eq-1", "LineRate", "Float32", "scr-1",
|
||||
changeTriggered: true, timerIntervalMs: 1000, historize: true, default);
|
||||
|
||||
v.ChangeTriggered.ShouldBeTrue();
|
||||
v.TimerIntervalMs.ShouldBe(1000);
|
||||
v.Historize.ShouldBeTrue();
|
||||
v.Enabled.ShouldBeTrue();
|
||||
(await svc.ListAsync(7, default)).Single().VirtualTagId.ShouldBe(v.VirtualTagId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VirtualTagService_update_enabled_toggles_flag()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new VirtualTagService(db);
|
||||
var v = await svc.AddAsync(7, "eq-1", "N", "Int32", "scr-1", true, null, false, default);
|
||||
|
||||
var disabled = await svc.UpdateEnabledAsync(7, v.VirtualTagId, false, default);
|
||||
disabled.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptedAlarmService_defaults_HistorizeToAveva_true_per_plan_decision_15()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptedAlarmService(db);
|
||||
|
||||
var a = await svc.AddAsync(9, "eq-1", "HighTemp", "LimitAlarm", severity: 800,
|
||||
messageTemplate: "{Temp} too high", predicateScriptId: "scr-9",
|
||||
historizeToAveva: true, retain: true, default);
|
||||
|
||||
a.HistorizeToAveva.ShouldBeTrue();
|
||||
a.Severity.ShouldBe(800);
|
||||
a.ScriptedAlarmId.ShouldStartWith("sal-");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_runs_successful_script_and_captures_writes()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """
|
||||
ctx.SetVirtualTag("Out", 42);
|
||||
return ctx.GetTag("In").Value;
|
||||
""";
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["In"] = new(123, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.Success);
|
||||
result.Output.ShouldBe(123);
|
||||
result.Writes["Out"].ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_missing_synthetic_input()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """return ctx.GetTag("A").Value;""";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.MissingInputs);
|
||||
result.Errors[0].ShouldContain("A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_extra_synthetic_input_not_referenced_by_script()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """return 1;"""; // no GetTag calls
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["Unexpected"] = new(0, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.UnknownInputs);
|
||||
result.Errors[0].ShouldContain("Unexpected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_non_literal_path()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """
|
||||
var p = "A";
|
||||
return ctx.GetTag(p).Value;
|
||||
""";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.DependencyRejected);
|
||||
result.Errors.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_surfaces_compile_error_as_Threw()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = "this is not valid C#;";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.Threw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistorianDiagnosticsService_reports_Disabled_for_null_sink()
|
||||
{
|
||||
var diag = new HistorianDiagnosticsService(NullAlarmHistorianSink.Instance);
|
||||
diag.GetStatus().DrainState.ShouldBe(HistorianDrainState.Disabled);
|
||||
diag.TryRetryDeadLettered().ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="IHubContext{THub}"/> that captures SendAsync invocations for
|
||||
/// assertion. Only the methods the <c>FleetStatusPoller</c> actually calls are implemented —
|
||||
/// other interface surface throws to fail fast if the poller evolves new dependencies.
|
||||
/// </summary>
|
||||
public sealed class RecordingHubContext<THub> : IHubContext<THub> where THub : Hub
|
||||
{
|
||||
public RecordingHubContext(RecordingHubClients clients) => Clients = clients;
|
||||
|
||||
public IHubClients Clients { get; }
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public sealed class RecordingHubClients : IHubClients
|
||||
{
|
||||
public readonly List<RecordedMessage> SentMessages = [];
|
||||
|
||||
public IClientProxy All => NotUsed();
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NotUsed();
|
||||
public IClientProxy Client(string connectionId) => NotUsed();
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NotUsed();
|
||||
public IClientProxy Group(string groupName) => new RecordingClientProxy(groupName, SentMessages);
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NotUsed();
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NotUsed();
|
||||
public IClientProxy User(string userId) => NotUsed();
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => NotUsed();
|
||||
|
||||
private static IClientProxy NotUsed() => throw new NotImplementedException("not used by FleetStatusPoller");
|
||||
}
|
||||
|
||||
public sealed class RecordingClientProxy(string target, List<RecordedMessage> sink) : IClientProxy
|
||||
{
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
sink.Add(new RecordedMessage(target, method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RecordedMessage(string Target, string Method, object?[] Args);
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RedundancyMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordRoleTransition_Increments_Counter_WithExpectedTags()
|
||||
{
|
||||
using var metrics = new RedundancyMetrics();
|
||||
using var listener = new MeterListener();
|
||||
var observed = new List<(long Value, Dictionary<string, object?> Tags)>();
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name == RedundancyMetrics.MeterName &&
|
||||
instrument.Name == "otopcua.redundancy.role_transition")
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
|
||||
{
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var tag in tags) dict[tag.Key] = tag.Value;
|
||||
observed.Add((value, dict));
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
metrics.RecordRoleTransition("c1", "node-a", "Primary", "Secondary");
|
||||
|
||||
observed.Count.ShouldBe(1);
|
||||
observed[0].Value.ShouldBe(1);
|
||||
observed[0].Tags["cluster.id"].ShouldBe("c1");
|
||||
observed[0].Tags["node.id"].ShouldBe("node-a");
|
||||
observed[0].Tags["from_role"].ShouldBe("Primary");
|
||||
observed[0].Tags["to_role"].ShouldBe("Secondary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetClusterCounts_Observed_Via_ObservableGauges()
|
||||
{
|
||||
using var metrics = new RedundancyMetrics();
|
||||
metrics.SetClusterCounts("c1", primary: 1, secondary: 2, stale: 0);
|
||||
metrics.SetClusterCounts("c2", primary: 0, secondary: 1, stale: 1);
|
||||
|
||||
var observations = new List<(string Name, long Value, string Cluster)>();
|
||||
using var listener = new MeterListener();
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name == RedundancyMetrics.MeterName)
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) =>
|
||||
{
|
||||
string? cluster = null;
|
||||
foreach (var t in tags) if (t.Key == "cluster.id") cluster = t.Value as string;
|
||||
observations.Add((instrument.Name, value, cluster ?? "?"));
|
||||
});
|
||||
listener.Start();
|
||||
listener.RecordObservableInstruments();
|
||||
|
||||
observations.ShouldContain(o => o.Name == "otopcua.redundancy.primary_count" && o.Cluster == "c1" && o.Value == 1);
|
||||
observations.ShouldContain(o => o.Name == "otopcua.redundancy.secondary_count" && o.Cluster == "c1" && o.Value == 2);
|
||||
observations.ShouldContain(o => o.Name == "otopcua.redundancy.stale_count" && o.Cluster == "c2" && o.Value == 1);
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ResilientLdapGroupRoleMappingService"/> — the Phase 6.2
|
||||
/// Stream A.2 resilience decorator (timeout → retry → in-memory-snapshot fallback)
|
||||
/// that guards <see cref="AdminRoleGrantResolver"/> against a transient Config DB outage.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ResilientLdapGroupRoleMappingServiceTests
|
||||
{
|
||||
// ── fake inner service ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Configurable in-memory <see cref="ILdapGroupRoleMappingService"/>. Throws on demand
|
||||
/// so we can exercise the resilience path without a real DB.
|
||||
/// </summary>
|
||||
private sealed class FakeInner : ILdapGroupRoleMappingService
|
||||
{
|
||||
private readonly IReadOnlyList<LdapGroupRoleMapping> _rows;
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public int ReadAttempts { get; private set; }
|
||||
|
||||
public FakeInner(IReadOnlyList<LdapGroupRoleMapping>? rows = null)
|
||||
=> _rows = rows ?? [];
|
||||
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
ReadAttempts++;
|
||||
if (ThrowOnRead) throw new InvalidOperationException("DB unavailable (test)");
|
||||
var set = ldapGroups.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(
|
||||
_rows.Where(r => set.Contains(r.LdapGroup)).ToList());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_rows);
|
||||
|
||||
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(row);
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── factory helper ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="ResilientLdapGroupRoleMappingService"/> backed by a real
|
||||
/// <see cref="ServiceCollection"/> that registers <paramref name="inner"/> under the
|
||||
/// keyed-service key <see cref="ResilientLdapGroupRoleMappingService.InnerServiceKey"/>.
|
||||
/// </summary>
|
||||
private static ResilientLdapGroupRoleMappingService Build(
|
||||
FakeInner inner,
|
||||
TimeSpan? timeout = null,
|
||||
int retryCount = 0)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton<ILdapGroupRoleMappingService>(
|
||||
ResilientLdapGroupRoleMappingService.InnerServiceKey, inner);
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
return new ResilientLdapGroupRoleMappingService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<ResilientLdapGroupRoleMappingService>.Instance,
|
||||
timeout ?? TimeSpan.FromSeconds(10),
|
||||
retryCount);
|
||||
}
|
||||
|
||||
// ── tests — resilience pipeline ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DbSuccess_returns_result_and_seals_snapshot()
|
||||
{
|
||||
var row = Row("cn=ops", AdminRole.FleetAdmin);
|
||||
var fake = new FakeInner([row]);
|
||||
var svc = Build(fake);
|
||||
|
||||
var result = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].LdapGroup.ShouldBe("cn=ops");
|
||||
fake.ReadAttempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DbFailure_with_snapshot_returns_cached_result()
|
||||
{
|
||||
var row = Row("cn=ops", AdminRole.FleetAdmin);
|
||||
var fake = new FakeInner([row]);
|
||||
var svc = Build(fake, retryCount: 0);
|
||||
|
||||
// First call succeeds — populates the snapshot.
|
||||
await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
|
||||
|
||||
// Now break the DB.
|
||||
fake.ThrowOnRead = true;
|
||||
|
||||
var fallback = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
|
||||
|
||||
fallback.Count.ShouldBe(1);
|
||||
fallback[0].LdapGroup.ShouldBe("cn=ops");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DbFailure_without_snapshot_returns_empty_list()
|
||||
{
|
||||
var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]) { ThrowOnRead = true };
|
||||
var svc = Build(fake, retryCount: 0);
|
||||
|
||||
var result = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
|
||||
|
||||
// Empty list — the static LdapOptions.GroupToRole bootstrap in AdminRoleGrantResolver
|
||||
// is the lock-out-proof floor; no DB rows means only static dict grants fire.
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DbFailure_retries_before_fallback()
|
||||
{
|
||||
var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]) { ThrowOnRead = true };
|
||||
// retryCount=2: 1 initial + 2 retries = 3 attempts total before falling back.
|
||||
var svc = Build(fake, timeout: TimeSpan.FromSeconds(30), retryCount: 2);
|
||||
|
||||
var result = await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
|
||||
|
||||
fake.ReadAttempts.ShouldBe(3, "1 initial + 2 retries before snapshot fallback");
|
||||
result.ShouldBeEmpty("no prior snapshot — empty fallback, not a throw");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_groups_bypasses_pipeline_and_returns_empty()
|
||||
{
|
||||
var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]);
|
||||
var svc = Build(fake);
|
||||
|
||||
var result = await svc.GetByGroupsAsync([], CancellationToken.None);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
fake.ReadAttempts.ShouldBe(0, "pipeline must not fire for empty group list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_without_fallback()
|
||||
{
|
||||
var fake = new FakeInner([Row("cn=ops", AdminRole.FleetAdmin)]);
|
||||
var svc = Build(fake, retryCount: 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => svc.GetByGroupsAsync(["cn=ops"], cts.Token));
|
||||
}
|
||||
|
||||
// ── tests — snapshot key semantics ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_is_keyed_by_group_set_regardless_of_order()
|
||||
{
|
||||
var row1 = Row("cn=a", AdminRole.FleetAdmin);
|
||||
var row2 = Row("cn=b", AdminRole.ConfigEditor);
|
||||
var fake = new FakeInner([row1, row2]);
|
||||
var svc = Build(fake, retryCount: 0);
|
||||
|
||||
// Seed the snapshot with [b, a] order.
|
||||
await svc.GetByGroupsAsync(["cn=b", "cn=a"], CancellationToken.None);
|
||||
fake.ThrowOnRead = true;
|
||||
|
||||
// Request with [a, b] order — same canonical key → fallback snapshot available.
|
||||
var fallback = await svc.GetByGroupsAsync(["cn=a", "cn=b"], CancellationToken.None);
|
||||
fallback.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Different_group_sets_have_independent_snapshots()
|
||||
{
|
||||
var row1 = Row("cn=ops", AdminRole.FleetAdmin);
|
||||
var row2 = Row("cn=viewer", AdminRole.ConfigViewer);
|
||||
var fake = new FakeInner([row1, row2]);
|
||||
var svc = Build(fake, retryCount: 0);
|
||||
|
||||
// Seed snapshot for cn=ops only.
|
||||
await svc.GetByGroupsAsync(["cn=ops"], CancellationToken.None);
|
||||
fake.ThrowOnRead = true;
|
||||
|
||||
// cn=viewer never had a successful call → no snapshot → empty fallback.
|
||||
var fallback = await svc.GetByGroupsAsync(["cn=viewer"], CancellationToken.None);
|
||||
fallback.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ── tests — CacheKey helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CacheKey_is_order_independent()
|
||||
{
|
||||
var key1 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=a", "cn=b", "cn=c"]);
|
||||
var key2 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=c", "cn=a", "cn=b"]);
|
||||
key1.ShouldBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheKey_is_case_insensitive()
|
||||
{
|
||||
var key1 = ResilientLdapGroupRoleMappingService.CacheKey(["CN=Ops"]);
|
||||
var key2 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=ops"]);
|
||||
key1.ShouldBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheKey_distinguishes_different_sets()
|
||||
{
|
||||
var key1 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=a"]);
|
||||
var key2 = ResilientLdapGroupRoleMappingService.CacheKey(["cn=b"]);
|
||||
key1.ShouldNotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CacheKey_single_group_roundtrips()
|
||||
{
|
||||
var key = ResilientLdapGroupRoleMappingService.CacheKey(["cn=fleet-admin"]);
|
||||
key.ShouldBe("cn=fleet-admin");
|
||||
}
|
||||
|
||||
// ── pass-through methods ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAllAsync_passes_through_to_inner()
|
||||
{
|
||||
var row = Row("cn=ops", AdminRole.FleetAdmin);
|
||||
var fake = new FakeInner([row]);
|
||||
var svc = Build(fake);
|
||||
|
||||
var result = await svc.ListAllAsync(CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_passes_through_to_inner()
|
||||
{
|
||||
var row = Row("cn=ops", AdminRole.FleetAdmin);
|
||||
var fake = new FakeInner();
|
||||
var svc = Build(fake);
|
||||
|
||||
var created = await svc.CreateAsync(row, CancellationToken.None);
|
||||
created.ShouldBe(row);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_passes_through_to_inner()
|
||||
{
|
||||
var fake = new FakeInner();
|
||||
var svc = Build(fake);
|
||||
|
||||
// Should not throw.
|
||||
await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LdapGroupRoleMapping Row(string group, AdminRole role) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
LdapGroup = group,
|
||||
Role = role,
|
||||
IsSystemWide = true,
|
||||
ClusterId = null,
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RoleMapperTests
|
||||
{
|
||||
[Fact]
|
||||
public void Maps_single_group_to_single_role()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
};
|
||||
RoleMapper.Map(["ReadOnly"], mapping).ShouldBe(["ConfigViewer"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Group_match_is_case_insensitive()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
};
|
||||
RoleMapper.Map(["readonly"], mapping).ShouldContain("ConfigViewer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_with_multiple_matching_groups_gets_all_distinct_roles()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
["ReadWrite"] = "ConfigEditor",
|
||||
["AlarmAck"] = "FleetAdmin",
|
||||
};
|
||||
var roles = RoleMapper.Map(["ReadOnly", "ReadWrite", "AlarmAck"], mapping);
|
||||
roles.ShouldContain("ConfigViewer");
|
||||
roles.ShouldContain("ConfigEditor");
|
||||
roles.ShouldContain("FleetAdmin");
|
||||
roles.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_group_is_ignored()
|
||||
{
|
||||
var mapping = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ConfigViewer",
|
||||
};
|
||||
RoleMapper.Map(["UnrelatedGroup"], mapping).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_mapping_returns_empty_roles()
|
||||
{
|
||||
RoleMapper.Map(["ReadOnly"], new Dictionary<string, string>()).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ScriptLogHub"/> helper logic — line parsing, filter matching,
|
||||
/// and the tail/append file reading utilities. The SignalR streaming method itself
|
||||
/// (TailLogAsync) is not integration-tested here; the helpers are tested in isolation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLogHubTests
|
||||
{
|
||||
// ── ParseLine ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_extracts_INF_level_from_serilog_format()
|
||||
{
|
||||
var line = ScriptLogHub.ParseLine("[12:34:56 INF] Script ran successfully");
|
||||
line.Level.ShouldBe("INF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_extracts_WRN_level()
|
||||
{
|
||||
var line = ScriptLogHub.ParseLine("2026-05-18T12:34:56.000Z [WRN] Script timed out");
|
||||
line.Level.ShouldBe("WRN");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_extracts_ERR_level()
|
||||
{
|
||||
var line = ScriptLogHub.ParseLine("[ERR] NullReferenceException in script");
|
||||
line.Level.ShouldBe("ERR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_defaults_to_INF_when_no_level_token()
|
||||
{
|
||||
var line = ScriptLogHub.ParseLine("Some unformatted log text with no level");
|
||||
line.Level.ShouldBe("INF");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_extracts_ScriptName_property()
|
||||
{
|
||||
var raw = """[INF] Evaluation complete ScriptName="line-rate-calc" Value=42""";
|
||||
var line = ScriptLogHub.ParseLine(raw);
|
||||
line.ScriptName.ShouldBe("line-rate-calc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_ScriptName_is_null_when_property_absent()
|
||||
{
|
||||
var raw = "[INF] Server started";
|
||||
var line = ScriptLogHub.ParseLine(raw);
|
||||
line.ScriptName.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_preserves_Raw_text_unchanged()
|
||||
{
|
||||
var raw = "[WRN] Script error ScriptName=\"my-alarm\" Details=\"bad value\"";
|
||||
var line = ScriptLogHub.ParseLine(raw);
|
||||
line.Raw.ShouldBe(raw);
|
||||
}
|
||||
|
||||
// ── Matches ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Matches_null_filter_accepts_all_lines()
|
||||
{
|
||||
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
|
||||
ScriptLogHub.Matches(line, null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_empty_filter_accepts_all_lines()
|
||||
{
|
||||
var line = new ScriptLogLine("raw", "INF", "some-script", DateTime.UtcNow);
|
||||
ScriptLogHub.Matches(line, "").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_whitespace_filter_accepts_all_lines()
|
||||
{
|
||||
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
|
||||
ScriptLogHub.Matches(line, " ").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_filter_matches_script_name_case_insensitive()
|
||||
{
|
||||
var line = new ScriptLogLine("raw", "INF", "line-rate-calc", DateTime.UtcNow);
|
||||
ScriptLogHub.Matches(line, "Line-Rate").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_filter_rejects_line_with_different_script_name()
|
||||
{
|
||||
var line = new ScriptLogLine("raw", "INF", "oven-temp-alarm", DateTime.UtcNow);
|
||||
ScriptLogHub.Matches(line, "line-rate").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_filter_rejects_line_with_null_script_name()
|
||||
{
|
||||
var line = new ScriptLogLine("raw", "INF", null, DateTime.UtcNow);
|
||||
ScriptLogHub.Matches(line, "line-rate").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_filter_supports_partial_match()
|
||||
{
|
||||
var line = new ScriptLogLine("raw", "INF", "line-rate-calc", DateTime.UtcNow);
|
||||
ScriptLogHub.Matches(line, "rate").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ── ReadTailLines / ReadNewLines ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReadTailLines_returns_empty_list_for_empty_file()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, string.Empty);
|
||||
var lines = ScriptLogHub.ReadTailLines(path, 50, out var pos);
|
||||
lines.ShouldBeEmpty();
|
||||
pos.ShouldBe(0);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadTailLines_returns_all_lines_when_fewer_than_n()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllLines(path, ["line1", "line2", "line3"]);
|
||||
var lines = ScriptLogHub.ReadTailLines(path, 50, out _);
|
||||
lines.ShouldContain("line1");
|
||||
lines.ShouldContain("line2");
|
||||
lines.ShouldContain("line3");
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadTailLines_returns_last_n_lines_when_file_is_large()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
var allLines = Enumerable.Range(1, 20).Select(i => $"line{i}").ToArray();
|
||||
File.WriteAllLines(path, allLines);
|
||||
var lines = ScriptLogHub.ReadTailLines(path, 5, out _);
|
||||
lines.Count.ShouldBe(5);
|
||||
lines[^1].ShouldBe("line20");
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadNewLines_returns_empty_when_nothing_appended()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, "existing content\n");
|
||||
ScriptLogHub.ReadTailLines(path, 10, out var pos); // seed position
|
||||
var newLines = ScriptLogHub.ReadNewLines(path, ref pos);
|
||||
newLines.ShouldBeEmpty();
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadNewLines_returns_appended_lines()
|
||||
{
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, "existing\n");
|
||||
ScriptLogHub.ReadTailLines(path, 10, out var pos); // set position to end
|
||||
|
||||
// Append new content
|
||||
File.AppendAllText(path, "appended-line-1\nappended-line-2\n");
|
||||
|
||||
var newLines = ScriptLogHub.ReadNewLines(path, ref pos);
|
||||
newLines.ShouldContain("appended-line-1");
|
||||
newLines.ShouldContain("appended-line-2");
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #155 — TagService CRUD round-trip coverage. Mirrors the EquipmentService test shape;
|
||||
/// uses EF Core InMemory so no SQL Server is required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TagServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_And_List_Surfaces_The_Tag()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var svc = new TagService(ctx);
|
||||
|
||||
var created = await svc.CreateAsync(draftId: 1, NewTag("Temp"), TestContext.Current.CancellationToken);
|
||||
created.TagId.ShouldNotBeNullOrEmpty();
|
||||
created.GenerationId.ShouldBe(1);
|
||||
|
||||
var list = await svc.ListAsync(1, ct: TestContext.Current.CancellationToken);
|
||||
list.Count.ShouldBe(1);
|
||||
list[0].Name.ShouldBe("Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_Filters_By_DriverInstance()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var svc = new TagService(ctx);
|
||||
await svc.CreateAsync(1, NewTag("a", driver: "drv-1"), TestContext.Current.CancellationToken);
|
||||
await svc.CreateAsync(1, NewTag("b", driver: "drv-2"), TestContext.Current.CancellationToken);
|
||||
await svc.CreateAsync(1, NewTag("c", driver: "drv-1"), TestContext.Current.CancellationToken);
|
||||
|
||||
var d1 = await svc.ListAsync(1, driverInstanceId: "drv-1", ct: TestContext.Current.CancellationToken);
|
||||
d1.Count.ShouldBe(2);
|
||||
d1.Select(t => t.Name).ShouldBe(new[] { "a", "c" }, ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_Persists_Editable_Fields()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var svc = new TagService(ctx);
|
||||
var t = await svc.CreateAsync(1, NewTag("Original"), TestContext.Current.CancellationToken);
|
||||
|
||||
t.Name = "Renamed";
|
||||
t.DataType = "Float";
|
||||
t.AccessLevel = TagAccessLevel.ReadWrite;
|
||||
t.TagConfig = "{\"addressString\":\"40001:F\"}";
|
||||
await svc.UpdateAsync(t, TestContext.Current.CancellationToken);
|
||||
|
||||
var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken))[0];
|
||||
fresh.Name.ShouldBe("Renamed");
|
||||
fresh.DataType.ShouldBe("Float");
|
||||
fresh.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
|
||||
fresh.TagConfig.ShouldContain("40001:F");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TagConfig_With_Advanced_Modbus_Fields_RoundTrips_Through_Factory()
|
||||
{
|
||||
// #156 — TagsTab serializes advanced fields (deadband / unitId / coalesceProhibited)
|
||||
// into TagConfig as a structured JSON object alongside addressString. Confirm the
|
||||
// shape survives a DB round-trip AND that ModbusDriverFactoryExtensions.BuildTag's
|
||||
// JSON consumer accepts it. If the field names drift between the UI and the factory,
|
||||
// this test catches it before users do.
|
||||
using var ctx = NewContext();
|
||||
var svc = new TagService(ctx);
|
||||
|
||||
var advancedConfig = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
addressString = "40001:F:CDAB",
|
||||
deadband = 0.5,
|
||||
unitId = 7,
|
||||
coalesceProhibited = true,
|
||||
});
|
||||
var t = NewTag("Tank");
|
||||
t.TagConfig = advancedConfig;
|
||||
await svc.CreateAsync(1, t, TestContext.Current.CancellationToken);
|
||||
|
||||
var fresh = (await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).Single();
|
||||
fresh.TagConfig.ShouldContain("addressString");
|
||||
fresh.TagConfig.ShouldContain("deadband");
|
||||
fresh.TagConfig.ShouldContain("unitId");
|
||||
fresh.TagConfig.ShouldContain("coalesceProhibited");
|
||||
|
||||
// Build the wrapping driver-config JSON the factory consumes (one tag, the structured
|
||||
// config above as its TagConfig), then construct a driver from it. If any field name
|
||||
// doesn't match the DTO, BuildTag throws here.
|
||||
var driverConfig = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
host = "127.0.0.1",
|
||||
tags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "Tank",
|
||||
addressString = "40001:F:CDAB",
|
||||
deadband = 0.5,
|
||||
unitId = (byte)7,
|
||||
coalesceProhibited = true,
|
||||
},
|
||||
},
|
||||
});
|
||||
Should.NotThrow(() => ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverFactoryExtensions.CreateInstance(
|
||||
"advanced-rt", driverConfig));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Removes_The_Row()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
var svc = new TagService(ctx);
|
||||
var t = await svc.CreateAsync(1, NewTag("Doomed"), TestContext.Current.CancellationToken);
|
||||
|
||||
await svc.DeleteAsync(t.TagRowId, TestContext.Current.CancellationToken);
|
||||
|
||||
(await svc.ListAsync(1, ct: TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static Tag NewTag(string name, string driver = "drv-1") => new()
|
||||
{
|
||||
TagId = string.Empty, // CreateAsync auto-assigns
|
||||
DriverInstanceId = driver,
|
||||
Name = name,
|
||||
DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{}",
|
||||
};
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsImpactAnalyzerTests
|
||||
{
|
||||
private static UnsTreeSnapshot TwoAreaSnapshot() => new()
|
||||
{
|
||||
DraftGenerationId = 1,
|
||||
RevisionToken = new DraftRevisionToken("rev-1"),
|
||||
Areas =
|
||||
[
|
||||
new UnsAreaSummary("area-pack", "Packaging", ["line-oven", "line-wrap"]),
|
||||
new UnsAreaSummary("area-asm", "Assembly", ["line-weld"]),
|
||||
],
|
||||
Lines =
|
||||
[
|
||||
new UnsLineSummary("line-oven", "Oven-2", EquipmentCount: 14, TagCount: 237),
|
||||
new UnsLineSummary("line-wrap", "Wrapper", EquipmentCount: 3, TagCount: 40),
|
||||
new UnsLineSummary("line-weld", "Welder", EquipmentCount: 5, TagCount: 80),
|
||||
],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LineMove_Counts_Affected_Equipment_And_Tags()
|
||||
{
|
||||
var snapshot = TwoAreaSnapshot();
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMove,
|
||||
SourceClusterId: "c1", TargetClusterId: "c1",
|
||||
SourceLineId: "line-oven",
|
||||
TargetAreaId: "area-asm");
|
||||
|
||||
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
|
||||
preview.AffectedEquipmentCount.ShouldBe(14);
|
||||
preview.AffectedTagCount.ShouldBe(237);
|
||||
preview.RevisionToken.Value.ShouldBe("rev-1");
|
||||
preview.HumanReadableSummary.ShouldContain("'Oven-2'");
|
||||
preview.HumanReadableSummary.ShouldContain("'Assembly'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossCluster_LineMove_Throws()
|
||||
{
|
||||
var snapshot = TwoAreaSnapshot();
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMove,
|
||||
SourceClusterId: "c1", TargetClusterId: "c2",
|
||||
SourceLineId: "line-oven",
|
||||
TargetAreaId: "area-asm");
|
||||
|
||||
Should.Throw<CrossClusterMoveRejectedException>(
|
||||
() => UnsImpactAnalyzer.Analyze(snapshot, move));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineMove_With_UnknownSource_Throws_Validation()
|
||||
{
|
||||
var snapshot = TwoAreaSnapshot();
|
||||
var move = new UnsMoveOperation(
|
||||
UnsMoveKind.LineMove, "c1", "c1",
|
||||
SourceLineId: "line-does-not-exist",
|
||||
TargetAreaId: "area-asm");
|
||||
|
||||
Should.Throw<UnsMoveValidationException>(
|
||||
() => UnsImpactAnalyzer.Analyze(snapshot, move));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineMove_With_UnknownTarget_Throws_Validation()
|
||||
{
|
||||
var snapshot = TwoAreaSnapshot();
|
||||
var move = new UnsMoveOperation(
|
||||
UnsMoveKind.LineMove, "c1", "c1",
|
||||
SourceLineId: "line-oven",
|
||||
TargetAreaId: "area-nowhere");
|
||||
|
||||
Should.Throw<UnsMoveValidationException>(
|
||||
() => UnsImpactAnalyzer.Analyze(snapshot, move));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineMove_To_Area_WithSameName_Warns_AboutAmbiguity()
|
||||
{
|
||||
var snapshot = new UnsTreeSnapshot
|
||||
{
|
||||
DraftGenerationId = 1,
|
||||
RevisionToken = new DraftRevisionToken("rev-1"),
|
||||
Areas =
|
||||
[
|
||||
new UnsAreaSummary("area-a", "Packaging", ["line-1"]),
|
||||
new UnsAreaSummary("area-b", "Assembly", ["line-2"]),
|
||||
],
|
||||
Lines =
|
||||
[
|
||||
new UnsLineSummary("line-1", "Oven", 10, 100),
|
||||
new UnsLineSummary("line-2", "Oven", 5, 50),
|
||||
],
|
||||
};
|
||||
var move = new UnsMoveOperation(
|
||||
UnsMoveKind.LineMove, "c1", "c1",
|
||||
SourceLineId: "line-1",
|
||||
TargetAreaId: "area-b");
|
||||
|
||||
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
|
||||
preview.CascadeWarnings.ShouldContain(w => w.Contains("already has a line named 'Oven'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AreaRename_Cascades_AcrossAllLines()
|
||||
{
|
||||
var snapshot = TwoAreaSnapshot();
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.AreaRename,
|
||||
SourceClusterId: "c1", TargetClusterId: "c1",
|
||||
SourceAreaId: "area-pack",
|
||||
NewName: "Packaging-West");
|
||||
|
||||
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
|
||||
preview.AffectedEquipmentCount.ShouldBe(14 + 3, "sum of lines in 'Packaging'");
|
||||
preview.AffectedTagCount.ShouldBe(237 + 40);
|
||||
preview.HumanReadableSummary.ShouldContain("'Packaging-West'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineMerge_CrossArea_Warns()
|
||||
{
|
||||
var snapshot = TwoAreaSnapshot();
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMerge,
|
||||
SourceClusterId: "c1", TargetClusterId: "c1",
|
||||
SourceLineId: "line-oven",
|
||||
TargetLineId: "line-weld");
|
||||
|
||||
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
|
||||
preview.AffectedEquipmentCount.ShouldBe(14);
|
||||
preview.CascadeWarnings.ShouldContain(w => w.Contains("different areas"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineMerge_SameArea_NoWarning()
|
||||
{
|
||||
var snapshot = TwoAreaSnapshot();
|
||||
var move = new UnsMoveOperation(
|
||||
Kind: UnsMoveKind.LineMerge,
|
||||
SourceClusterId: "c1", TargetClusterId: "c1",
|
||||
SourceLineId: "line-oven",
|
||||
TargetLineId: "line-wrap");
|
||||
|
||||
var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
|
||||
|
||||
preview.CascadeWarnings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftRevisionToken_Matches_OnEqualValues()
|
||||
{
|
||||
var a = new DraftRevisionToken("rev-1");
|
||||
var b = new DraftRevisionToken("rev-1");
|
||||
var c = new DraftRevisionToken("rev-2");
|
||||
|
||||
a.Matches(b).ShouldBeTrue();
|
||||
a.Matches(c).ShouldBeFalse();
|
||||
a.Matches(null).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsServiceMoveTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") },
|
||||
equipmentLines: new[] { "line-a", "line-a", "line-b" });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
snap.Areas.Count.ShouldBe(2);
|
||||
snap.Lines.Count.ShouldBe(3);
|
||||
snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2);
|
||||
snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1);
|
||||
snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var first = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
var second = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var before = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None);
|
||||
var after = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
|
||||
after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLineAsync_WithMatchingToken_Reparents_Line()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None);
|
||||
await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None);
|
||||
|
||||
var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||
moved.UnsAreaId.ShouldBe("area-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict()
|
||||
{
|
||||
using var ctx = NewContext();
|
||||
Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" },
|
||||
lines: new[] { ("line-a", "area-1") });
|
||||
var svc = new UnsService(ctx);
|
||||
|
||||
// Simulate a peer operator's concurrent edit between our preview + commit.
|
||||
var stale = new DraftRevisionToken("0000000000000000");
|
||||
|
||||
await Should.ThrowAsync<DraftRevisionConflictException>(() =>
|
||||
svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None));
|
||||
|
||||
var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a");
|
||||
row.UnsAreaId.ShouldBe("area-1");
|
||||
}
|
||||
|
||||
private static void Seed(OtOpcUaConfigDbContext ctx, long draftId,
|
||||
IEnumerable<string> areas,
|
||||
IEnumerable<(string line, string area)> lines,
|
||||
IEnumerable<string>? equipmentLines = null)
|
||||
{
|
||||
foreach (var a in areas)
|
||||
{
|
||||
ctx.UnsAreas.Add(new UnsArea
|
||||
{
|
||||
GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a,
|
||||
});
|
||||
}
|
||||
foreach (var (line, area) in lines)
|
||||
{
|
||||
ctx.UnsLines.Add(new UnsLine
|
||||
{
|
||||
GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line,
|
||||
});
|
||||
}
|
||||
foreach (var lineId in equipmentLines ?? [])
|
||||
{
|
||||
ctx.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = draftId,
|
||||
EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15],
|
||||
EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
|
||||
UnsLineId = lineId, Name = "x", MachineCode = "m",
|
||||
});
|
||||
}
|
||||
ctx.SaveChanges();
|
||||
}
|
||||
|
||||
private static OtOpcUaConfigDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(opts);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ValidatedNodeAclAuthoringServiceTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
|
||||
public ValidatedNodeAclAuthoringServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"val-nodeacl-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_Rejects_NonePermissions()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
|
||||
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
|
||||
draftGenerationId: 1, clusterId: "c1", ldapGroup: "cn=ops",
|
||||
scopeKind: NodeAclScopeKind.Cluster, scopeId: null,
|
||||
permissions: NodePermissions.None, notes: null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_Rejects_ClusterScope_With_ScopeId()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
|
||||
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
|
||||
1, "c1", "cn=ops",
|
||||
NodeAclScopeKind.Cluster, scopeId: "not-null-wrong",
|
||||
NodePermissions.Read, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_Rejects_SubClusterScope_Without_ScopeId()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
|
||||
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
|
||||
1, "c1", "cn=ops",
|
||||
NodeAclScopeKind.Equipment, scopeId: null,
|
||||
NodePermissions.Read, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_Succeeds_When_Valid()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
|
||||
var row = await svc.GrantAsync(
|
||||
1, "c1", "cn=ops",
|
||||
NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.Read | NodePermissions.Browse, "fleet reader", CancellationToken.None);
|
||||
|
||||
row.LdapGroup.ShouldBe("cn=ops");
|
||||
row.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.Browse);
|
||||
row.NodeAclId.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_Rejects_DuplicateScopeGroup_Pair()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.Read, null, CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
|
||||
1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.WriteOperate, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_SameGroup_DifferentScope_IsAllowed()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.Read, null, CancellationToken.None);
|
||||
|
||||
var tagRow = await svc.GrantAsync(1, "c1", "cn=ops",
|
||||
NodeAclScopeKind.Tag, scopeId: "tag-xyz",
|
||||
NodePermissions.WriteOperate, null, CancellationToken.None);
|
||||
|
||||
tagRow.ScopeKind.ShouldBe(NodeAclScopeKind.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Grant_SameGroupScope_DifferentDraft_IsAllowed()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.Read, null, CancellationToken.None);
|
||||
|
||||
var draft2Row = await svc.GrantAsync(2, "c1", "cn=ops",
|
||||
NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.Read, null, CancellationToken.None);
|
||||
|
||||
draft2Row.GenerationId.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePermissions_Rejects_None()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
var row = await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.Read, null, CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<InvalidNodeAclGrantException>(
|
||||
() => svc.UpdatePermissionsAsync(row.NodeAclRowId, NodePermissions.None, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePermissions_RoundTrips_NewFlags()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
var row = await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
|
||||
NodePermissions.Read, null, CancellationToken.None);
|
||||
|
||||
var updated = await svc.UpdatePermissionsAsync(row.NodeAclRowId,
|
||||
NodePermissions.Read | NodePermissions.WriteOperate, "bumped", CancellationToken.None);
|
||||
|
||||
updated.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
|
||||
updated.Notes.ShouldBe("bumped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePermissions_MissingRow_Throws()
|
||||
{
|
||||
var svc = new ValidatedNodeAclAuthoringService(_db);
|
||||
|
||||
await Should.ThrowAsync<InvalidNodeAclGrantException>(
|
||||
() => svc.UpdatePermissionsAsync(Guid.NewGuid(), NodePermissions.Read, null, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,322 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #219 — end-to-end server integration coverage for the <see cref="IAlarmSource"/>
|
||||
/// dispatch path. Boots the full OPC UA stack + a fake <see cref="IAlarmSource"/> driver,
|
||||
/// opens a client session, raises a driver-side transition, and asserts it propagates
|
||||
/// through <c>GenericDriverNodeManager</c>'s alarm forwarder into
|
||||
/// <c>DriverNodeManager.ConditionSink</c>, updates the server-side
|
||||
/// <c>AlarmConditionState</c> child attributes (Severity / Message / ActiveState), and
|
||||
/// flows out to an OPC UA subscription on the Server object's EventNotifier.
|
||||
///
|
||||
/// Companion to <see cref="HistoryReadIntegrationTests"/> which covers the
|
||||
/// <see cref="IHistoryProvider"/> dispatch path; together they close the server-side
|
||||
/// integration gap for optional driver capabilities (plan decision #62).
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class AlarmSubscribeIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48700 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaAlarmTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-alarm-test-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
private AlarmDriver _driver = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
_driver = new AlarmDriver();
|
||||
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaAlarmTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:AlarmTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
HealthEndpointsEnabled = false,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_alarm_transition_updates_server_side_AlarmConditionState_node()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
||||
|
||||
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||
SubscriptionHandle: new FakeHandle("sub"),
|
||||
SourceNodeId: "Tank.HiHi",
|
||||
ConditionId: "cond-1",
|
||||
AlarmType: "Active",
|
||||
Message: "Level exceeded upper-upper",
|
||||
Severity: AlarmSeverity.High,
|
||||
SourceTimestampUtc: DateTime.UtcNow));
|
||||
|
||||
// The alarm-condition node's identifier is the driver full-reference + ".Condition"
|
||||
// (DriverNodeManager.VariableHandle.MarkAsAlarmCondition). Server-side state changes
|
||||
// are applied synchronously under DriverNodeManager.Lock inside ConditionSink.OnTransition,
|
||||
// so by the time RaiseAlarm returns the node state has been flushed.
|
||||
var conditionNodeId = new NodeId("Tank.HiHi.Condition", nsIndex);
|
||||
|
||||
// Browse the condition node for the well-known Part-9 child variables. The stack
|
||||
// materializes Severity / Message / ActiveState / AckedState as children below the
|
||||
// AlarmConditionState; their NodeIds are allocated by the stack so we discover them
|
||||
// by BrowseName rather than guessing.
|
||||
var browseDescriptions = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = conditionNodeId,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = 0,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
};
|
||||
session.Browse(null, null, 0, browseDescriptions, out var browseResults, out _);
|
||||
var children = browseResults[0].References
|
||||
.ToDictionary(r => r.BrowseName.Name,
|
||||
r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
children.ShouldContainKey("Severity");
|
||||
children.ShouldContainKey("Message");
|
||||
children.ShouldContainKey("ActiveState");
|
||||
|
||||
// Severity / Message / ActiveState.Id reflect the driver-fired transition — verifies
|
||||
// the forwarder → ConditionSink.OnTransition → alarm.ClearChangeMasks pipeline
|
||||
// landed the new values in addressable child nodes. DriverNodeManager's
|
||||
// AssignSymbolicDescendantIds keeps each child reachable under the node manager's
|
||||
// namespace so Read resolves against the predefined-node dictionary.
|
||||
var severity = session.ReadValue(children["Severity"]);
|
||||
var message = session.ReadValue(children["Message"]);
|
||||
severity.Value.ShouldBe((ushort)700); // AlarmSeverity.High → 700 (MapSeverity)
|
||||
((LocalizedText)message.Value).Text.ShouldBe("Level exceeded upper-upper");
|
||||
|
||||
// ActiveState exposes its boolean Id as a HasProperty child.
|
||||
var activeBrowse = new BrowseDescriptionCollection
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = children["ActiveState"],
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = ReferenceTypeIds.HasProperty,
|
||||
IncludeSubtypes = true,
|
||||
ResultMask = (uint)BrowseResultMask.All,
|
||||
},
|
||||
};
|
||||
session.Browse(null, null, 0, activeBrowse, out var activeChildren, out _);
|
||||
var idRef = activeChildren[0].References.Single(r => r.BrowseName.Name == "Id");
|
||||
var activeId = session.ReadValue(ExpandedNodeId.ToNodeId(idRef.NodeId, session.NamespaceUris));
|
||||
activeId.Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_alarm_event_flows_to_client_subscription_on_Server_EventNotifier()
|
||||
{
|
||||
// AddRootNotifier registers the AlarmConditionState as a Server-object notifier
|
||||
// source, so a subscription with an EventFilter on Server receives the
|
||||
// ReportEvent calls ConditionSink emits per-transition.
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync();
|
||||
|
||||
var received = new List<EventFieldList>();
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
filter.WhereClause = new ContentFilter();
|
||||
filter.WhereClause.Push(FilterOperator.OfType,
|
||||
new LiteralOperand { Value = new Variant(ObjectTypeIds.AlarmConditionType) });
|
||||
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = ObjectIds.Server,
|
||||
AttributeId = Attributes.EventNotifier,
|
||||
NodeClass = NodeClass.Object,
|
||||
SamplingInterval = 0,
|
||||
QueueSize = 100,
|
||||
Filter = filter,
|
||||
};
|
||||
item.Notification += (_, e) =>
|
||||
{
|
||||
if (e.NotificationValue is EventFieldList fields)
|
||||
{
|
||||
lock (received) { received.Add(fields); gate.TrySetResult(); }
|
||||
}
|
||||
};
|
||||
subscription.AddItem(item);
|
||||
await subscription.ApplyChangesAsync();
|
||||
|
||||
// Give the publish loop a tick to establish before firing.
|
||||
await Task.Delay(200);
|
||||
|
||||
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("sub"), "Tank.HiHi", "cond-x", "Active",
|
||||
"High-high tripped", AlarmSeverity.Critical, DateTime.UtcNow));
|
||||
|
||||
var delivered = await Task.WhenAny(gate.Task, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
delivered.ShouldBe(gate.Task, "alarm event must arrive at the client within 10s");
|
||||
|
||||
EventFieldList first;
|
||||
lock (received) first = received[0];
|
||||
// Filter field order: 0=EventId, 1=SourceName, 2=Message, 3=Severity.
|
||||
((LocalizedText)first.EventFields[2].Value).Text.ShouldBe("High-high tripped");
|
||||
first.EventFields[3].Value.ShouldBe((ushort)900); // Critical → 900
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_IsAlarm_variable_registers_its_own_condition_node_in_the_driver_namespace()
|
||||
{
|
||||
// Tag-scoped alarm wiring: DiscoverAsync declares two IsAlarm variables and calls
|
||||
// MarkAsAlarmCondition on each. The server-side DriverNodeManager wraps each call in
|
||||
// a CapturingHandle that creates a sibling AlarmConditionState + registers a sink
|
||||
// under the driver full-reference. Browse should show both condition nodes with
|
||||
// distinct NodeIds using the FullReference + ".Condition" convention.
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alarm-driver");
|
||||
|
||||
_driver.RaiseAlarm(new AlarmEventArgs(
|
||||
new FakeHandle("sub"), "Tank.HiHi", "c", "Active", "first", AlarmSeverity.High,
|
||||
DateTime.UtcNow));
|
||||
|
||||
var attrs = new ReadValueIdCollection
|
||||
{
|
||||
new() { NodeId = new NodeId("Tank.HiHi.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
||||
new() { NodeId = new NodeId("Heater.OverTemp.Condition", nsIndex), AttributeId = Attributes.DisplayName },
|
||||
};
|
||||
session.Read(null, 0, TimestampsToReturn.Neither, attrs, out var results, out _);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[1].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
((LocalizedText)results[0].Value).Text.ShouldBe("Tank.HiHi");
|
||||
((LocalizedText)results[1].Value).Text.ShouldBe("Heater.OverTemp");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaAlarmTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:AlarmTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaAlarmTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaAlarmTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub <see cref="IAlarmSource"/> driver. <see cref="DiscoverAsync"/> emits two alarm-
|
||||
/// bearing variables (so tag-scoped fan-out can be asserted); <see cref="RaiseAlarm"/>
|
||||
/// fires <see cref="OnAlarmEvent"/> exactly like a real driver would.
|
||||
/// </summary>
|
||||
private sealed class AlarmDriver : IDriver, ITagDiscovery, IAlarmSource
|
||||
{
|
||||
public string DriverInstanceId => "alarm-driver";
|
||||
public string DriverType => "AlarmStub";
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var tank = builder.Folder("Tank", "Tank");
|
||||
var hiHi = tank.Variable("HiHi", "HiHi", new DriverAttributeInfo(
|
||||
"Tank.HiHi", DriverDataType.Boolean, false, null,
|
||||
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
hiHi.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||
"Tank.HiHi", AlarmSeverity.High, "High-high alarm"));
|
||||
|
||||
var heater = builder.Folder("Heater", "Heater");
|
||||
var ot = heater.Variable("OverTemp", "OverTemp", new DriverAttributeInfo(
|
||||
"Heater.OverTemp", DriverDataType.Boolean, false, null,
|
||||
SecurityClassification.FreeAccess, false, IsAlarm: true));
|
||||
ot.MarkAsAlarmCondition(new AlarmConditionInfo(
|
||||
"Heater.OverTemp", AlarmSeverity.Critical, "Over-temperature"));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> _, CancellationToken __)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId { get; } = diagnosticId;
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Alarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// Server-level alarm-condition state-machine tests added in PR 2.2. Ports the live
|
||||
/// transition cases from <c>GalaxyAlarmTrackerTests</c> against the new
|
||||
/// driver-agnostic <see cref="AlarmConditionService"/>: sub-attribute references come
|
||||
/// from <see cref="AlarmConditionInfo"/>, value changes flow as
|
||||
/// <see cref="DataValueSnapshot"/> instead of MX-specific <c>Vtq</c>, and the ack
|
||||
/// write path is decoupled into <see cref="IAlarmAcknowledger"/>.
|
||||
/// </summary>
|
||||
public sealed class AlarmConditionServiceTests
|
||||
{
|
||||
private const string ConditionId = "TankFarm.Tank1.Level.HiHi";
|
||||
private const string InAlarmRef = "TankFarm.Tank1.Level.HiHi.InAlarm";
|
||||
private const string PriorityRef = "TankFarm.Tank1.Level.HiHi.Priority";
|
||||
private const string DescRef = "TankFarm.Tank1.Level.HiHi.DescAttrName";
|
||||
private const string AckedRef = "TankFarm.Tank1.Level.HiHi.Acked";
|
||||
private const string AckMsgWriteRef = "TankFarm.Tank1.Level.HiHi.AckMsg";
|
||||
|
||||
private static AlarmConditionInfo Info(
|
||||
string? inAlarm = InAlarmRef, string? priority = PriorityRef,
|
||||
string? desc = DescRef, string? acked = AckedRef, string? ackMsg = AckMsgWriteRef)
|
||||
=> new(
|
||||
SourceName: ConditionId,
|
||||
InitialSeverity: AlarmSeverity.Medium,
|
||||
InitialDescription: null,
|
||||
InAlarmRef: inAlarm,
|
||||
PriorityRef: priority,
|
||||
DescAttrNameRef: desc,
|
||||
AckedRef: acked,
|
||||
AckMsgWriteRef: ackMsg);
|
||||
|
||||
private static DataValueSnapshot Bool(bool v) =>
|
||||
new(v, StatusCode: 0, SourceTimestampUtc: DateTime.UtcNow, ServerTimestampUtc: DateTime.UtcNow);
|
||||
private static DataValueSnapshot Int(int v) =>
|
||||
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
|
||||
private static DataValueSnapshot Str(string v) =>
|
||||
new(v, 0, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
private sealed class FakeAcker : IAlarmAcknowledger
|
||||
{
|
||||
public readonly ConcurrentQueue<(string Ref, string Comment)> Writes = new();
|
||||
public bool ReturnValue { get; set; } = true;
|
||||
|
||||
public Task<bool> WriteAckMessageAsync(string ackMsgWriteRef, string comment, CancellationToken cancellationToken)
|
||||
{
|
||||
Writes.Enqueue((ackMsgWriteRef, comment));
|
||||
return Task.FromResult(ReturnValue);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_AddsCondition_AndExposesSubscribedReferences()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.TrackedCount.ShouldBe(1);
|
||||
var refs = svc.GetSubscribedReferences();
|
||||
refs.ShouldContain(InAlarmRef);
|
||||
refs.ShouldContain(PriorityRef);
|
||||
refs.ShouldContain(DescRef);
|
||||
refs.ShouldContain(AckedRef);
|
||||
refs.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_IsIdempotentOnRepeatCall()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
|
||||
svc.Track(ConditionId, Info());
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.TrackedCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_OmitsNullSubAttributeRefs()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
|
||||
// Driver may not expose every sub-attribute (e.g. no .Acked observable).
|
||||
svc.Track(ConditionId, Info(priority: null, desc: null, acked: null));
|
||||
|
||||
svc.GetSubscribedReferences().ShouldBe(new[] { InAlarmRef });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InAlarmFalseToTrue_FiresActiveTransition()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(PriorityRef, Int(500));
|
||||
svc.OnValueChanged(DescRef, Str("Tank level high-high"));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
|
||||
transitions.Count.ShouldBe(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.Transition.ShouldBe(AlarmStateTransition.Active);
|
||||
t.Priority.ShouldBe(500);
|
||||
t.Description.ShouldBe("Tank level high-high");
|
||||
t.ConditionId.ShouldBe(ConditionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InAlarmTrueToFalse_FiresInactiveTransition()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(false));
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.Transition.ShouldBe(AlarmStateTransition.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AckedFalseToTrue_FiresAcknowledged_WhileActive()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true)); // Active, resets Acked → false
|
||||
svc.OnValueChanged(AckedRef, Bool(true)); // Acknowledged
|
||||
|
||||
transitions.Count.ShouldBe(2);
|
||||
transitions.TryDequeue(out _);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.Transition.ShouldBe(AlarmStateTransition.Acknowledged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AckedTransitionWhileInactive_DoesNotFire()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
// Initial Acked=true on subscribe (alarm at rest, pre-ack'd) — must not fire.
|
||||
svc.OnValueChanged(AckedRef, Bool(true));
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedActiveTransitions_ResetAckedFlag()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
// Cycle 1: active → ack → inactive → active again
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
svc.OnValueChanged(AckedRef, Bool(true));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(false));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true)); // re-arms — Acked must reset to false
|
||||
svc.OnValueChanged(AckedRef, Bool(true)); // produces a fresh Acknowledged
|
||||
|
||||
// Active, Acknowledged, Inactive, Active, Acknowledged
|
||||
transitions.Count.ShouldBe(5);
|
||||
var ordered = transitions.Select(t => t.Transition).ToArray();
|
||||
ordered.ShouldBe(new[]
|
||||
{
|
||||
AlarmStateTransition.Active,
|
||||
AlarmStateTransition.Acknowledged,
|
||||
AlarmStateTransition.Inactive,
|
||||
AlarmStateTransition.Active,
|
||||
AlarmStateTransition.Acknowledged,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_RoutesToAckerWithAckMsgRef()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var acker = new FakeAcker();
|
||||
svc.Track(ConditionId, Info(), acker);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "operator-1: cleared", CancellationToken.None);
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
acker.Writes.Count.ShouldBe(1);
|
||||
acker.Writes.TryDequeue(out var w).ShouldBeTrue();
|
||||
w.Ref.ShouldBe(AckMsgWriteRef);
|
||||
w.Comment.ShouldBe("operator-1: cleared");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsFalse_WhenConditionUntracked()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var acker = new FakeAcker();
|
||||
svc.Track("OtherCondition", Info(), acker);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
|
||||
|
||||
ok.ShouldBeFalse();
|
||||
acker.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsFalse_WhenNoAckerRegistered()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info(), acker: null);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
|
||||
|
||||
ok.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_ReturnsFalse_WhenAckMsgRefMissing()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var acker = new FakeAcker();
|
||||
svc.Track(ConditionId, Info(ackMsg: null), acker);
|
||||
|
||||
var ok = await svc.AcknowledgeAsync(ConditionId, "comment");
|
||||
|
||||
ok.ShouldBeFalse();
|
||||
acker.Writes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReportsLatestFields()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
svc.OnValueChanged(PriorityRef, Int(900));
|
||||
svc.OnValueChanged(DescRef, Str("MyAlarm"));
|
||||
svc.OnValueChanged(AckedRef, Bool(true));
|
||||
|
||||
var snap = svc.Snapshot();
|
||||
|
||||
snap.Count.ShouldBe(1);
|
||||
snap[0].ConditionId.ShouldBe(ConditionId);
|
||||
snap[0].InAlarm.ShouldBeTrue();
|
||||
snap[0].Acked.ShouldBeTrue();
|
||||
snap[0].Priority.ShouldBe(900);
|
||||
snap[0].Description.ShouldBe("MyAlarm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnValueChanged_ForUnknownReference_IsSilentlyIgnored()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
var transitions = new ConcurrentQueue<AlarmConditionTransition>();
|
||||
svc.TransitionRaised += (_, t) => transitions.Enqueue(t);
|
||||
|
||||
svc.OnValueChanged("Some.Random.Tag.InAlarm", Bool(true));
|
||||
|
||||
transitions.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrack_RemovesConditionAndReleasesReferences()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.Untrack(ConditionId);
|
||||
|
||||
svc.TrackedCount.ShouldBe(0);
|
||||
svc.GetSubscribedReferences().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrack_NonexistentConditionIsNoOp()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
Should.NotThrow(() => svc.Untrack("does-not-exist"));
|
||||
svc.TrackedCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Track_ThrowsAfterDisposal()
|
||||
{
|
||||
var svc = new AlarmConditionService();
|
||||
svc.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() => svc.Track(ConditionId, Info()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnValueChanged_AfterDisposal_IsSilentlyDropped()
|
||||
{
|
||||
var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
svc.Dispose();
|
||||
|
||||
// Stale callbacks during disposal must not throw.
|
||||
Should.NotThrow(() => svc.OnValueChanged(InAlarmRef, Bool(true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityCoercion_AcceptsCommonNumericTypes()
|
||||
{
|
||||
using var svc = new AlarmConditionService();
|
||||
svc.Track(ConditionId, Info());
|
||||
|
||||
svc.OnValueChanged(PriorityRef, new DataValueSnapshot((short)123, 0, null, DateTime.UtcNow));
|
||||
svc.OnValueChanged(InAlarmRef, Bool(true));
|
||||
|
||||
var snap = svc.Snapshot()[0];
|
||||
snap.Priority.ShouldBe(123);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// PR B.3 — pins the routing decision DriverNodeManager makes when registering
|
||||
/// an AlarmConditionState: drivers that implement <see cref="IAlarmSource"/>
|
||||
/// get an acknowledger that calls AcknowledgeAsync (driver-native path); drivers
|
||||
/// that don't fall back to the IWritable sub-attribute write.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverAlarmSourceAcknowledgerRoutingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Driver_with_IAlarmSource_is_recognized()
|
||||
{
|
||||
IDriver driver = new FakeDriverWithAlarmSource("drv-1");
|
||||
(driver is IAlarmSource).ShouldBeTrue(
|
||||
"fakes that participate in the routing-test fixture must report IAlarmSource");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_without_IAlarmSource_falls_to_writable_path()
|
||||
{
|
||||
IDriver driver = new FakeDriverNoAlarmSource("drv-2");
|
||||
(driver is IAlarmSource).ShouldBeFalse(
|
||||
"drivers without IAlarmSource take the legacy DriverWritableAcknowledger path");
|
||||
}
|
||||
|
||||
private sealed class FakeDriverWithAlarmSource(string id) : IDriver, IAlarmSource
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "FakeAlarmSource";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("h"));
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
private void NoUnusedWarning() => OnAlarmEvent?.Invoke(this, null!);
|
||||
}
|
||||
|
||||
private sealed class FakeDriverNoAlarmSource(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId { get; } = id;
|
||||
public string DriverType => "FakeNoAlarmSource";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHandle(string id) : IAlarmSubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId { get; } = id;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ApplyLeaseRegistryTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
public DateTime Utc { get; set; } = T0;
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyRegistry_NotInProgress()
|
||||
{
|
||||
var reg = new ApplyLeaseRegistry();
|
||||
reg.IsApplyInProgress.ShouldBeFalse();
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BeginAndDispose_ClosesLease()
|
||||
{
|
||||
var reg = new ApplyLeaseRegistry();
|
||||
|
||||
await using (reg.BeginApplyLease(1, Guid.NewGuid()))
|
||||
{
|
||||
reg.IsApplyInProgress.ShouldBeTrue();
|
||||
reg.OpenLeaseCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
reg.IsApplyInProgress.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_OnException_StillCloses()
|
||||
{
|
||||
var reg = new ApplyLeaseRegistry();
|
||||
var publishId = Guid.NewGuid();
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await using var lease = reg.BeginApplyLease(1, publishId);
|
||||
throw new InvalidOperationException("publish failed");
|
||||
});
|
||||
|
||||
reg.IsApplyInProgress.ShouldBeFalse("await-using semantics must close the lease on exception");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_TwiceIsSafe()
|
||||
{
|
||||
var reg = new ApplyLeaseRegistry();
|
||||
var lease = reg.BeginApplyLease(1, Guid.NewGuid());
|
||||
|
||||
await lease.DisposeAsync();
|
||||
await lease.DisposeAsync();
|
||||
|
||||
reg.IsApplyInProgress.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleLeases_Concurrent_StayIsolated()
|
||||
{
|
||||
var reg = new ApplyLeaseRegistry();
|
||||
var id1 = Guid.NewGuid();
|
||||
var id2 = Guid.NewGuid();
|
||||
|
||||
await using var lease1 = reg.BeginApplyLease(1, id1);
|
||||
await using var lease2 = reg.BeginApplyLease(2, id2);
|
||||
|
||||
reg.OpenLeaseCount.ShouldBe(2);
|
||||
await lease1.DisposeAsync();
|
||||
reg.IsApplyInProgress.ShouldBeTrue("lease2 still open");
|
||||
await lease2.DisposeAsync();
|
||||
reg.IsApplyInProgress.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watchdog_ClosesStaleLeases()
|
||||
{
|
||||
var clock = new FakeTimeProvider();
|
||||
var reg = new ApplyLeaseRegistry(applyMaxDuration: TimeSpan.FromMinutes(10), timeProvider: clock);
|
||||
|
||||
_ = reg.BeginApplyLease(1, Guid.NewGuid()); // intentional leak; not awaited / disposed
|
||||
|
||||
// Lease still young → no-op.
|
||||
clock.Utc = T0.AddMinutes(5);
|
||||
reg.PruneStale().ShouldBe(0);
|
||||
reg.IsApplyInProgress.ShouldBeTrue();
|
||||
|
||||
// Past the watchdog horizon → force-close.
|
||||
clock.Utc = T0.AddMinutes(11);
|
||||
var closed = reg.PruneStale();
|
||||
|
||||
closed.ShouldBe(1);
|
||||
reg.IsApplyInProgress.ShouldBeFalse("ServiceLevel can't stick at mid-apply after a crashed publisher");
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Watchdog_LeavesRecentLeaseAlone()
|
||||
{
|
||||
var clock = new FakeTimeProvider();
|
||||
var reg = new ApplyLeaseRegistry(applyMaxDuration: TimeSpan.FromMinutes(10), timeProvider: clock);
|
||||
|
||||
await using var lease = reg.BeginApplyLease(1, Guid.NewGuid());
|
||||
clock.Utc = T0.AddMinutes(3);
|
||||
|
||||
reg.PruneStale().ShouldBe(0);
|
||||
reg.IsApplyInProgress.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AuthorizationGateTests
|
||||
{
|
||||
private static NodeScope Scope(string cluster = "c1", string? tag = "tag1") => new()
|
||||
{
|
||||
ClusterId = cluster,
|
||||
NamespaceId = "ns",
|
||||
UnsAreaId = "area",
|
||||
UnsLineId = "line",
|
||||
EquipmentId = "eq",
|
||||
TagId = tag,
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
private static NodeAcl Row(string group, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = "c1",
|
||||
LdapGroup = group,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
ScopeId = null,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows)
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
// Pass the cache so BuildSessionState stamps AuthGenerationId = 1 (Core-002 fix).
|
||||
return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache);
|
||||
}
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullIdentity_StrictMode_Denies()
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
gate.IsAllowed(null, OpcUaOperation.Read, Scope()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullIdentity_LaxMode_Allows()
|
||||
{
|
||||
var gate = MakeGate(strict: false, rows: []);
|
||||
gate.IsAllowed(null, OpcUaOperation.Read, Scope()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityWithoutLdapGroups_StrictMode_Denies()
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
var identity = new UserIdentity(); // anonymous, no LDAP groups
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdentityWithoutLdapGroups_LaxMode_Allows()
|
||||
{
|
||||
var gate = MakeGate(strict: false, rows: []);
|
||||
var identity = new UserIdentity();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapGroupWithGrant_Allows()
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: [Row("cn=ops", NodePermissions.Read)]);
|
||||
var identity = new FakeIdentity("ops-user", ["cn=ops"]);
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapGroupWithoutGrant_StrictMode_Denies()
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: [Row("cn=ops", NodePermissions.Read)]);
|
||||
var identity = new FakeIdentity("other-user", ["cn=other"]);
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrongOperation_Denied()
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: [Row("cn=ops", NodePermissions.Read)]);
|
||||
var identity = new FakeIdentity("ops-user", ["cn=ops"]);
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.WriteOperate, Scope()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionState_IncludesLdapGroups()
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
var identity = new FakeIdentity("u", ["cn=a", "cn=b"]);
|
||||
|
||||
var state = gate.BuildSessionState(identity, "c1");
|
||||
|
||||
state.ShouldNotBeNull();
|
||||
state!.LdapGroups.Count.ShouldBe(2);
|
||||
state.ClusterId.ShouldBe("c1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSessionState_ReturnsNull_ForIdentityWithoutLdapGroups()
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
|
||||
gate.BuildSessionState(new UserIdentity(), "c1").ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Evaluator stub that always returns a fixed verdict — lets the gate's
|
||||
/// verdict handling be exercised independent of the trie evaluator (which only ever
|
||||
/// produces Allow / NotGranted).</summary>
|
||||
private sealed class FixedVerdictEvaluator(AuthorizationVerdict verdict) : IPermissionEvaluator
|
||||
{
|
||||
public AuthorizationDecision Authorize(UserAuthorizationState session, OpcUaOperation operation, NodeScope scope)
|
||||
=> new(verdict, []);
|
||||
}
|
||||
|
||||
// Server-002 regression: an explicit Denied verdict must be honoured in BOTH modes —
|
||||
// the lax-mode fallback covers only the indeterminate (NotGranted) case.
|
||||
|
||||
[Fact]
|
||||
public void ExplicitDeny_LaxMode_Denies()
|
||||
{
|
||||
var gate = new AuthorizationGate(new FixedVerdictEvaluator(AuthorizationVerdict.Denied), strictMode: false);
|
||||
var identity = new FakeIdentity("ops-user", ["cn=ops"]);
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitDeny_StrictMode_Denies()
|
||||
{
|
||||
var gate = new AuthorizationGate(new FixedVerdictEvaluator(AuthorizationVerdict.Denied), strictMode: true);
|
||||
var identity = new FakeIdentity("ops-user", ["cn=ops"]);
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotGranted_LaxMode_Allows()
|
||||
{
|
||||
var gate = new AuthorizationGate(new FixedVerdictEvaluator(AuthorizationVerdict.NotGranted), strictMode: false);
|
||||
var identity = new FakeIdentity("ops-user", ["cn=ops"]);
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotGranted_StrictMode_Denies()
|
||||
{
|
||||
var gate = new AuthorizationGate(new FixedVerdictEvaluator(AuthorizationVerdict.NotGranted), strictMode: true);
|
||||
var identity = new FakeIdentity("ops-user", ["cn=ops"]);
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DriverNodeManager.FilterBrowseReferences"/> — Phase 6.2
|
||||
/// Stream C Browse gating. Verifies that references to nodes the session isn't
|
||||
/// allowed to browse are removed silently, while allowed references pass through.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BrowseGatingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate_null_leaves_references_untouched()
|
||||
{
|
||||
var refs = new List<ReferenceDescription>
|
||||
{
|
||||
NewRef("c1/area/line/eq/tag1"),
|
||||
NewRef("c1/area/line/eq/tag2"),
|
||||
};
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, new UserIdentity(), gate: null, scopeResolver: null);
|
||||
|
||||
refs.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_reference_list_is_a_no_op()
|
||||
{
|
||||
var refs = new List<ReferenceDescription>();
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
var resolver = new NodeScopeResolver("c1");
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, new UserIdentity(), gate, resolver);
|
||||
|
||||
refs.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_references_are_removed()
|
||||
{
|
||||
var refs = new List<ReferenceDescription>
|
||||
{
|
||||
NewRef("c1/area/line/eq/tag1"),
|
||||
NewRef("c1/area/line/eq/tag2"),
|
||||
};
|
||||
|
||||
// Strict mode with no ACL rows → everyone is denied.
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
var resolver = new NodeScopeResolver("c1");
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
|
||||
|
||||
refs.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allowed_references_remain()
|
||||
{
|
||||
var refs = new List<ReferenceDescription>
|
||||
{
|
||||
NewRef("c1/area/line/eq/tag1"),
|
||||
NewRef("c1/area/line/eq/tag2"),
|
||||
};
|
||||
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-ops", NodePermissions.Browse),
|
||||
]);
|
||||
var resolver = new NodeScopeResolver("c1");
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
|
||||
|
||||
refs.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_string_identifiers_bypass_the_gate()
|
||||
{
|
||||
// A numeric-identifier reference (stack-synthesized standard type) must not be
|
||||
// filtered — only driver-materialized (string-id) nodes are subject to the authz trie.
|
||||
var refs = new List<ReferenceDescription>
|
||||
{
|
||||
new() { NodeId = new NodeId(62u) }, // VariableTypeIds.BaseVariableType or similar
|
||||
NewRef("c1/area/line/eq/tag1"),
|
||||
};
|
||||
|
||||
// Strict + no grants → would deny everything, but the numeric ref bypasses.
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
var resolver = new NodeScopeResolver("c1");
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
|
||||
|
||||
refs.Count.ShouldBe(1);
|
||||
refs[0].NodeId.Identifier.ShouldBe(62u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lax_mode_null_identity_keeps_references()
|
||||
{
|
||||
var refs = new List<ReferenceDescription> { NewRef("c1/area/line/eq/tag1") };
|
||||
var gate = MakeGate(strict: false, rows: []);
|
||||
var resolver = new NodeScopeResolver("c1");
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, userIdentity: null, gate, resolver);
|
||||
|
||||
refs.Count.ShouldBe(1, "lax mode keeps the pre-Phase-6.2 behaviour — everything visible");
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
private static ReferenceDescription NewRef(string fullRef) => new()
|
||||
{
|
||||
NodeId = new NodeId(fullRef, 2),
|
||||
BrowseName = new QualifiedName("browse"),
|
||||
DisplayName = new LocalizedText("display"),
|
||||
};
|
||||
|
||||
private static NodeAcl Row(string group, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = "c1",
|
||||
LdapGroup = group,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
ScopeId = null,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows)
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache);
|
||||
}
|
||||
|
||||
private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups);
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DriverNodeManager.GateCallMethodRequests"/> and
|
||||
/// <see cref="DriverNodeManager.MapCallOperation"/> — Phase 6.2 Stream C method-Call
|
||||
/// gating covering the Part 9 alarm Acknowledge / Confirm methods plus generic
|
||||
/// driver-exposed method nodes.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CallGatingTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapCallOperation_Acknowledge_maps_to_AlarmAcknowledge()
|
||||
{
|
||||
DriverNodeManager.MapCallOperation(MethodIds.AcknowledgeableConditionType_Acknowledge)
|
||||
.ShouldBe(OpcUaOperation.AlarmAcknowledge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCallOperation_Confirm_maps_to_AlarmConfirm()
|
||||
{
|
||||
DriverNodeManager.MapCallOperation(MethodIds.AcknowledgeableConditionType_Confirm)
|
||||
.ShouldBe(OpcUaOperation.AlarmConfirm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCallOperation_AddComment_maps_to_AlarmAcknowledge()
|
||||
{
|
||||
// AddComment has no dedicated permission bit; it gates at the Acknowledge tier.
|
||||
DriverNodeManager.MapCallOperation(MethodIds.ConditionType_AddComment)
|
||||
.ShouldBe(OpcUaOperation.AlarmAcknowledge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCallOperation_generic_method_maps_to_Call()
|
||||
{
|
||||
// Arbitrary driver-exposed method NodeId — falls through to generic Call.
|
||||
DriverNodeManager.MapCallOperation(new NodeId("driver-method", 2))
|
||||
.ShouldBe(OpcUaOperation.Call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCallOperation_shelve_method_in_index_maps_to_AlarmShelve()
|
||||
{
|
||||
// Shelve methods carry per-instance NodeIds; membership in the indexed set
|
||||
// (built during the address-space build) is how they resolve to AlarmShelve.
|
||||
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
|
||||
var index = new HashSet<NodeId> { shelveMethodId };
|
||||
|
||||
DriverNodeManager.MapCallOperation(shelveMethodId, index)
|
||||
.ShouldBe(OpcUaOperation.AlarmShelve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCallOperation_shelve_method_not_in_index_falls_through_to_Call()
|
||||
{
|
||||
// A shelve-shaped NodeId that wasn't indexed (e.g. no scripted alarms) is
|
||||
// indistinguishable from a generic method node and gates as Call.
|
||||
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
|
||||
|
||||
DriverNodeManager.MapCallOperation(shelveMethodId, new HashSet<NodeId>())
|
||||
.ShouldBe(OpcUaOperation.Call);
|
||||
DriverNodeManager.MapCallOperation(shelveMethodId, shelveMethodIds: null)
|
||||
.ShouldBe(OpcUaOperation.Call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_shelve_call_gets_BadUserAccessDenied()
|
||||
{
|
||||
var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2);
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
// Operator has AlarmAcknowledge but NOT AlarmShelve — shelve must be denied.
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]);
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(
|
||||
calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"),
|
||||
shelveMethodIds: new HashSet<NodeId> { shelveMethodId });
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allowed_shelve_call_passes_through()
|
||||
{
|
||||
var shelveMethodId = new NodeId("c1/area/line/eq/alarm1.Condition.ShelvingState.OneShotShelve", 2);
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", shelveMethodId),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-eng", NodePermissions.AlarmShelve)]);
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(
|
||||
calls, errors, NewIdentity("alice", "grp-eng"), gate, new NodeScopeResolver("c1"),
|
||||
shelveMethodIds: new HashSet<NodeId> { shelveMethodId });
|
||||
|
||||
errors[0].ShouldBeNull("AlarmShelve grant allows the shelve call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_null_leaves_errors_untouched()
|
||||
{
|
||||
var calls = new List<CallMethodRequest> { NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge) };
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(calls, errors, new UserIdentity(), gate: null, scopeResolver: null);
|
||||
|
||||
errors[0].ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_Acknowledge_call_gets_BadUserAccessDenied()
|
||||
{
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: true, rows: []); // no grants → deny
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allowed_Acknowledge_passes_through()
|
||||
{
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]);
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
errors[0].ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_batch_gates_per_item()
|
||||
{
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
|
||||
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Confirm),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null!, (ServiceResult)null! };
|
||||
// Grant Acknowledge but not Confirm — mixed outcome per item.
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.AlarmAcknowledge)]);
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
errors[0].ShouldBeNull("Acknowledge granted");
|
||||
ServiceResult.IsBad(errors[1]).ShouldBeTrue("Confirm not granted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pre_populated_error_is_preserved()
|
||||
{
|
||||
var calls = new List<CallMethodRequest> { NewCall("c1/area/line/eq/alarm1", NodeId.Null) };
|
||||
var errors = new List<ServiceResult> { new(StatusCodes.BadMethodInvalid) };
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(calls, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadMethodInvalid);
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
private static CallMethodRequest NewCall(string objectFullRef, NodeId methodId) => new()
|
||||
{
|
||||
ObjectId = new NodeId(objectFullRef, 2),
|
||||
MethodId = methodId,
|
||||
};
|
||||
|
||||
private static NodeAcl Row(string group, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = "c1",
|
||||
LdapGroup = group,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
ScopeId = null,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows)
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache);
|
||||
}
|
||||
|
||||
private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups);
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ClusterTopologyLoaderTests
|
||||
{
|
||||
private static ServerCluster Cluster(RedundancyMode mode = RedundancyMode.Warm) => new()
|
||||
{
|
||||
ClusterId = "c1",
|
||||
Name = "Warsaw-West",
|
||||
Enterprise = "zb",
|
||||
Site = "warsaw-west",
|
||||
RedundancyMode = mode,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
private static ClusterNode Node(string id, RedundancyRole role, string host, int port = 4840, string? appUri = null) => new()
|
||||
{
|
||||
NodeId = id,
|
||||
ClusterId = "c1",
|
||||
RedundancyRole = role,
|
||||
Host = host,
|
||||
OpcUaPort = port,
|
||||
ApplicationUri = appUri ?? $"urn:{host}:OtOpcUa",
|
||||
CreatedBy = "test",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void SingleNode_Standalone_Loads()
|
||||
{
|
||||
var cluster = Cluster(RedundancyMode.None);
|
||||
var nodes = new[] { Node("A", RedundancyRole.Standalone, "hostA") };
|
||||
|
||||
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
|
||||
|
||||
topology.SelfNodeId.ShouldBe("A");
|
||||
topology.SelfRole.ShouldBe(RedundancyRole.Standalone);
|
||||
topology.Peers.ShouldBeEmpty();
|
||||
topology.SelfApplicationUri.ShouldBe("urn:hostA:OtOpcUa");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoNode_Cluster_LoadsSelfAndPeer()
|
||||
{
|
||||
var cluster = Cluster();
|
||||
var nodes = new[]
|
||||
{
|
||||
Node("A", RedundancyRole.Primary, "hostA"),
|
||||
Node("B", RedundancyRole.Secondary, "hostB"),
|
||||
};
|
||||
|
||||
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
|
||||
|
||||
topology.SelfNodeId.ShouldBe("A");
|
||||
topology.SelfRole.ShouldBe(RedundancyRole.Primary);
|
||||
topology.Peers.Count.ShouldBe(1);
|
||||
topology.Peers[0].NodeId.ShouldBe("B");
|
||||
topology.Peers[0].Role.ShouldBe(RedundancyRole.Secondary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerUriArray_Puts_Self_First_Peers_SortedLexicographically()
|
||||
{
|
||||
var cluster = Cluster();
|
||||
var nodes = new[]
|
||||
{
|
||||
Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:A"),
|
||||
Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:B"),
|
||||
};
|
||||
|
||||
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
|
||||
|
||||
topology.ServerUriArray().ShouldBe(["urn:A", "urn:B"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyNodes_Throws()
|
||||
{
|
||||
Should.Throw<InvalidTopologyException>(
|
||||
() => ClusterTopologyLoader.Load("A", Cluster(), []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfNotInCluster_Throws()
|
||||
{
|
||||
var nodes = new[] { Node("B", RedundancyRole.Primary, "hostB") };
|
||||
|
||||
Should.Throw<InvalidTopologyException>(
|
||||
() => ClusterTopologyLoader.Load("A-missing", Cluster(), nodes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThreeNodeCluster_Rejected_Per_Decision83()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
Node("A", RedundancyRole.Primary, "hostA"),
|
||||
Node("B", RedundancyRole.Secondary, "hostB"),
|
||||
Node("C", RedundancyRole.Secondary, "hostC"),
|
||||
};
|
||||
|
||||
var ex = Should.Throw<InvalidTopologyException>(
|
||||
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
|
||||
ex.Message.ShouldContain("decision #83");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateApplicationUri_Rejected()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
Node("A", RedundancyRole.Primary, "hostA", appUri: "urn:shared"),
|
||||
Node("B", RedundancyRole.Secondary, "hostB", appUri: "urn:shared"),
|
||||
};
|
||||
|
||||
var ex = Should.Throw<InvalidTopologyException>(
|
||||
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
|
||||
ex.Message.ShouldContain("ApplicationUri");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoPrimaries_InWarmMode_Rejected()
|
||||
{
|
||||
var nodes = new[]
|
||||
{
|
||||
Node("A", RedundancyRole.Primary, "hostA"),
|
||||
Node("B", RedundancyRole.Primary, "hostB"),
|
||||
};
|
||||
|
||||
var ex = Should.Throw<InvalidTopologyException>(
|
||||
() => ClusterTopologyLoader.Load("A", Cluster(RedundancyMode.Warm), nodes));
|
||||
ex.Message.ShouldContain("2 Primary");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossCluster_Node_Rejected()
|
||||
{
|
||||
var foreign = Node("B", RedundancyRole.Secondary, "hostB");
|
||||
foreign.ClusterId = "c-other";
|
||||
|
||||
var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA"), foreign };
|
||||
|
||||
Should.Throw<InvalidTopologyException>(
|
||||
() => ClusterTopologyLoader.Load("A", Cluster(), nodes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void None_Mode_Allows_Any_Role_Mix()
|
||||
{
|
||||
// Standalone clusters don't enforce Primary-count; operator can pick anything.
|
||||
var cluster = Cluster(RedundancyMode.None);
|
||||
var nodes = new[] { Node("A", RedundancyRole.Primary, "hostA") };
|
||||
|
||||
var topology = ClusterTopologyLoader.Load("A", cluster, nodes);
|
||||
|
||||
topology.Mode.ShouldBe(RedundancyMode.None);
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #12 hardening tests for the Phase 6.2 deferred authorization gates —
|
||||
/// Browse, Subscribe (CreateMonitoredItems), Alarm-acknowledge, and Call.
|
||||
///
|
||||
/// Fills the compliance-checklist gaps not covered by the existing per-gate unit
|
||||
/// tests (<see cref="BrowseGatingTests"/>, <see cref="MonitoredItemGatingTests"/>,
|
||||
/// <see cref="CallGatingTests"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item>Lax-mode fall-through for all four deferred gates</item>
|
||||
/// <item>Permission-bit isolation — Subscribe-only grant denies Read; HistoryRead-only
|
||||
/// grant denies Read (Phase 6.2 compliance item "HistoryRead uses its own flag")</item>
|
||||
/// <item>AlarmShelve resolves via the indexed shelve-method NodeId set (Task #24
|
||||
/// follow-up); an unindexed shelve-shaped NodeId still falls through to Call</item>
|
||||
/// <item>Complete OpcUaOperation → NodePermissions mapping coverage for deferred ops</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DeferredGateHardeningTests
|
||||
{
|
||||
private const string Cluster = "c1";
|
||||
|
||||
// ======================================================================
|
||||
// 1. Lax-mode fall-through — deferred gates
|
||||
// ======================================================================
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_gate_lax_mode_null_identity_keeps_items()
|
||||
{
|
||||
// In lax mode a session without LDAP groups must NOT be denied —
|
||||
// the pre-Phase-6.2 default path runs unchanged.
|
||||
var items = new List<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: false, rows: []); // lax, no grants
|
||||
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster));
|
||||
|
||||
errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour — no denial for unauthenticated sessions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_gate_lax_mode_identity_without_ldap_groups_keeps_items()
|
||||
{
|
||||
var items = new List<MonitoredItemCreateRequest> { NewMonitorRequest("c1/area/line/eq/tag1") };
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: false, rows: []);
|
||||
|
||||
// UserIdentity with no LDAP groups — lax gate should not deny
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster));
|
||||
|
||||
errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Call_gate_lax_mode_null_identity_keeps_calls()
|
||||
{
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: false, rows: []);
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(calls, errors, userIdentity: null, gate, new NodeScopeResolver(Cluster));
|
||||
|
||||
errors[0].ShouldBeNull("lax mode keeps pre-Phase-6.2 behaviour for null identity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Call_gate_lax_mode_identity_without_ldap_groups_keeps_calls()
|
||||
{
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
NewCall("c1/area/line/eq/alarm1", MethodIds.AcknowledgeableConditionType_Acknowledge),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: false, rows: []);
|
||||
|
||||
DriverNodeManager.GateCallMethodRequests(calls, errors, new UserIdentity(), gate, new NodeScopeResolver(Cluster));
|
||||
|
||||
errors[0].ShouldBeNull("lax mode allows sessions without LDAP groups");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 2. Flag isolation — Subscribe vs Read
|
||||
// ======================================================================
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_grant_does_not_imply_Read()
|
||||
{
|
||||
// Phase 6.2 compliance: Subscribe and Read are independent flags. A session
|
||||
// granted only Subscribe should NOT be able to read the current value.
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-subs", NodePermissions.Subscribe),
|
||||
]);
|
||||
var identity = NewIdentity("alice", "grp-subs");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeTrue("Subscribe grant allows CreateMonitoredItems");
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Subscribe grant alone does NOT allow Read");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_grant_does_not_imply_Subscribe()
|
||||
{
|
||||
// Read-only sessions can read current values but must not be allowed to subscribe.
|
||||
// This is a deliberate restriction: a data-centre operator monitoring a dashboard
|
||||
// via an OPC UA subscription is a different grant tier than "read once on demand".
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-readonly", NodePermissions.Read),
|
||||
]);
|
||||
var identity = NewIdentity("alice", "grp-readonly");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read grant allows Read");
|
||||
gate.IsAllowed(identity, OpcUaOperation.CreateMonitoredItems, scope).ShouldBeFalse("Read grant alone does NOT allow Subscribe");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 3. Flag isolation — HistoryRead vs Read
|
||||
// "HistoryRead uses its own flag" from Phase 6.2 Compliance Checklist
|
||||
// ======================================================================
|
||||
|
||||
[Fact]
|
||||
public void Read_grant_without_HistoryRead_denies_history_access()
|
||||
{
|
||||
// Phase 6.2 Compliance Checklist: "user with Read but not HistoryRead can read live
|
||||
// values but gets BadUserAccessDenied on HistoryRead."
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-read", NodePermissions.Read), // no HistoryRead bit
|
||||
]);
|
||||
var identity = NewIdentity("bob", "grp-read");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue("Read granted for current values");
|
||||
gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeFalse("HistoryRead NOT granted — own flag required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryRead_grant_without_Read_denies_current_value_read()
|
||||
{
|
||||
// Verify flag isolation in the other direction too — history archivers that can
|
||||
// pull history should not implicitly get live-read access.
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-hist", NodePermissions.HistoryRead), // no Read bit
|
||||
]);
|
||||
var identity = NewIdentity("carol", "grp-hist");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.HistoryRead, scope).ShouldBeTrue("HistoryRead granted for historical values");
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read NOT granted — own flag required");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 4. Flag isolation — Alarm bits
|
||||
// ======================================================================
|
||||
|
||||
[Fact]
|
||||
public void AlarmAcknowledge_grant_does_not_imply_AlarmConfirm()
|
||||
{
|
||||
// Each alarm-action bit is distinct — operators can acknowledge without also
|
||||
// having confirm authority.
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-ack", NodePermissions.AlarmAcknowledge),
|
||||
]);
|
||||
var identity = NewIdentity("dave", "grp-ack");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue();
|
||||
gate.IsAllowed(identity, OpcUaOperation.AlarmConfirm, scope).ShouldBeFalse("Confirm requires its own flag");
|
||||
gate.IsAllowed(identity, OpcUaOperation.AlarmShelve, scope).ShouldBeFalse("Shelve requires its own flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Browse_grant_does_not_grant_AlarmAcknowledge()
|
||||
{
|
||||
// Browse is granted for hierarchy navigation; it must not cascade to alarm actions.
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-browse", NodePermissions.Browse),
|
||||
]);
|
||||
var identity = NewIdentity("eve", "grp-browse");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue();
|
||||
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 5. AlarmShelve resolution in MapCallOperation (Task #24 follow-up)
|
||||
// Shelve methods carry per-instance NodeIds, so they resolve to AlarmShelve
|
||||
// via membership in the indexed shelve-method set rather than a constant match.
|
||||
// ======================================================================
|
||||
|
||||
[Fact]
|
||||
public void MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve()
|
||||
{
|
||||
// The address-space build indexes each scripted alarm's three ShelvedStateMachine
|
||||
// method NodeIds. A call whose MethodId is in that set gates as AlarmShelve, so
|
||||
// operators can be granted shelve rights independently of generic MethodCall.
|
||||
var shelveMethodId = new NodeId("al-1.Condition.ShelvingState.OneShotShelve", 2);
|
||||
var index = new HashSet<NodeId> { shelveMethodId };
|
||||
|
||||
DriverNodeManager.MapCallOperation(shelveMethodId, index).ShouldBe(OpcUaOperation.AlarmShelve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapCallOperation_unindexed_shelve_method_falls_through_to_Call()
|
||||
{
|
||||
// Without the index (e.g. a deployment with no scripted alarms) a shelve-shaped
|
||||
// NodeId is indistinguishable from a generic driver method and gates as Call.
|
||||
var shelveMethodId = new NodeId("ShelvedStateMachine.OneShotShelve", namespaceIndex: 0);
|
||||
DriverNodeManager.MapCallOperation(shelveMethodId).ShouldBe(OpcUaOperation.Call);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodCall_grant_allows_generic_Call()
|
||||
{
|
||||
// Users with MethodCall permission can invoke generic (non-alarm) driver methods.
|
||||
// Shelve methods now gate as AlarmShelve when indexed (see
|
||||
// MapCallOperation_indexed_shelve_method_maps_to_AlarmShelve).
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-eng", NodePermissions.MethodCall),
|
||||
]);
|
||||
var identity = NewIdentity("frank", "grp-eng");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeTrue("MethodCall grant covers generic Call");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 6. OpcUaOperation → NodePermissions mapping completeness (deferred ops)
|
||||
// Ensures the TriePermissionEvaluator maps all deferred operations correctly.
|
||||
// ======================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData(OpcUaOperation.Browse, NodePermissions.Browse)]
|
||||
[InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Subscribe)]
|
||||
[InlineData(OpcUaOperation.TransferSubscriptions,NodePermissions.Subscribe)]
|
||||
[InlineData(OpcUaOperation.Call, NodePermissions.MethodCall)]
|
||||
[InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.AlarmAcknowledge)]
|
||||
[InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.AlarmConfirm)]
|
||||
[InlineData(OpcUaOperation.AlarmShelve, NodePermissions.AlarmShelve)]
|
||||
public void Deferred_operation_maps_to_expected_permission_bit(OpcUaOperation op, NodePermissions required)
|
||||
{
|
||||
// Phase 6.2 Stream C compliance — every deferred gate operation must map to the
|
||||
// correct NodePermissions bit in TriePermissionEvaluator. Verifies the full
|
||||
// round-trip: grant exactly the required bit → IsAllowed returns true; no grant
|
||||
// → false.
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-test", required)]);
|
||||
var identity = NewIdentity("tester", "grp-test");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, op, scope).ShouldBeTrue(
|
||||
$"operation {op} should be allowed when {required} bit is granted");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OpcUaOperation.Browse, NodePermissions.Read)] // wrong bit
|
||||
[InlineData(OpcUaOperation.CreateMonitoredItems, NodePermissions.Read)] // wrong bit
|
||||
[InlineData(OpcUaOperation.Call, NodePermissions.Browse)] // wrong bit
|
||||
[InlineData(OpcUaOperation.AlarmAcknowledge, NodePermissions.Browse)] // wrong bit
|
||||
[InlineData(OpcUaOperation.AlarmConfirm, NodePermissions.Browse)] // wrong bit
|
||||
[InlineData(OpcUaOperation.AlarmShelve, NodePermissions.Browse)] // wrong bit
|
||||
public void Deferred_operation_denied_when_wrong_permission_bit_granted(OpcUaOperation op, NodePermissions wrongBit)
|
||||
{
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-wrong", wrongBit)]);
|
||||
var identity = NewIdentity("tester", "grp-wrong");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, op, scope).ShouldBeFalse(
|
||||
$"operation {op} must NOT be allowed by the {wrongBit} bit");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 7. Mixed multi-group union for deferred gates
|
||||
// ======================================================================
|
||||
|
||||
[Fact]
|
||||
public void Multi_group_union_for_deferred_gates()
|
||||
{
|
||||
// A session belonging to both grp-browse (Browse only) and grp-ack (AlarmAck only)
|
||||
// should be allowed both Browse and AlarmAcknowledge but not Read or Call.
|
||||
var gate = MakeGate(strict: true, rows:
|
||||
[
|
||||
Row("grp-browse", NodePermissions.Browse),
|
||||
Row("grp-ack", NodePermissions.AlarmAcknowledge),
|
||||
]);
|
||||
var identity = NewIdentity("grace", "grp-browse", "grp-ack");
|
||||
var scope = Scope();
|
||||
|
||||
gate.IsAllowed(identity, OpcUaOperation.Browse, scope).ShouldBeTrue("Browse from first group");
|
||||
gate.IsAllowed(identity, OpcUaOperation.AlarmAcknowledge, scope).ShouldBeTrue("AlarmAcknowledge from second group");
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse("Read not granted by either group");
|
||||
gate.IsAllowed(identity, OpcUaOperation.Call, scope).ShouldBeFalse("Call not granted by either group");
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 8. Strict vs lax for Browse gate (parity with existing BrowseGatingTests)
|
||||
// ======================================================================
|
||||
|
||||
[Fact]
|
||||
public void Browse_gate_strict_mode_denies_identity_with_ldap_groups_but_no_grant()
|
||||
{
|
||||
var refs = new List<ReferenceDescription> { NewRef("c1/area/line/eq/tag1") };
|
||||
// Identity has groups but no Browse ACL → strict mode must deny
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-other", NodePermissions.Read)]);
|
||||
var resolver = new NodeScopeResolver(Cluster);
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
|
||||
|
||||
refs.Count.ShouldBe(0, "strict mode: no Browse grant → reference removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Browse_gate_strict_mode_allows_with_Browse_grant()
|
||||
{
|
||||
var refs = new List<ReferenceDescription>
|
||||
{
|
||||
NewRef("c1/area/line/eq/tag1"),
|
||||
NewRef("c1/area/line/eq/tag2"),
|
||||
};
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.Browse)]);
|
||||
var resolver = new NodeScopeResolver(Cluster);
|
||||
|
||||
DriverNodeManager.FilterBrowseReferences(refs, NewIdentity("alice", "grp-ops"), gate, resolver);
|
||||
|
||||
refs.Count.ShouldBe(2, "strict mode: Browse grant → both references pass through");
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
private static NodeScope Scope() => new()
|
||||
{
|
||||
ClusterId = Cluster,
|
||||
NamespaceId = "ns",
|
||||
UnsAreaId = "area",
|
||||
UnsLineId = "line",
|
||||
EquipmentId = "eq",
|
||||
TagId = "tag1",
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
private static NodeAcl Row(string group, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = Cluster,
|
||||
LdapGroup = group,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
ScopeId = null,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows)
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, rows));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache);
|
||||
}
|
||||
|
||||
private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups);
|
||||
|
||||
private static MonitoredItemCreateRequest NewMonitorRequest(string fullRef) => new()
|
||||
{
|
||||
ItemToMonitor = new ReadValueId { NodeId = new NodeId(fullRef, 2) },
|
||||
};
|
||||
|
||||
private static CallMethodRequest NewCall(string objectFullRef, NodeId methodId) => new()
|
||||
{
|
||||
ObjectId = new NodeId(objectFullRef, 2),
|
||||
MethodId = methodId,
|
||||
};
|
||||
|
||||
private static ReferenceDescription NewRef(string fullRef) => new()
|
||||
{
|
||||
NodeId = new NodeId(fullRef, 2),
|
||||
BrowseName = new QualifiedName("browse"),
|
||||
DisplayName = new LocalizedText("display"),
|
||||
};
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverEquipmentContentRegistryTests
|
||||
{
|
||||
private static readonly EquipmentNamespaceContent EmptyContent =
|
||||
new(Areas: [], Lines: [], Equipment: [], Tags: []);
|
||||
|
||||
[Fact]
|
||||
public void Get_Returns_Null_For_Unknown_Driver()
|
||||
{
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
registry.Get("galaxy-prod").ShouldBeNull();
|
||||
registry.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_Then_Get_Returns_Stored_Content()
|
||||
{
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
registry.Set("galaxy-prod", EmptyContent);
|
||||
|
||||
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
|
||||
registry.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_Is_Case_Insensitive_For_Driver_Id()
|
||||
{
|
||||
// DriverInstanceId keys are OrdinalIgnoreCase across the codebase (Equipment /
|
||||
// Tag rows, walker grouping). Registry matches that contract so callers don't have
|
||||
// to canonicalize driver ids before lookup.
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
registry.Set("Galaxy-Prod", EmptyContent);
|
||||
registry.Get("galaxy-prod").ShouldBeSameAs(EmptyContent);
|
||||
registry.Get("GALAXY-PROD").ShouldBeSameAs(EmptyContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_Overwrites_Existing_Content_For_Same_Driver()
|
||||
{
|
||||
var registry = new DriverEquipmentContentRegistry();
|
||||
var first = EmptyContent;
|
||||
var second = new EquipmentNamespaceContent([], [], [], []);
|
||||
|
||||
registry.Set("galaxy-prod", first);
|
||||
registry.Set("galaxy-prod", second);
|
||||
|
||||
registry.Get("galaxy-prod").ShouldBeSameAs(second);
|
||||
registry.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #248 — covers the <see cref="DriverFactoryRegistry"/> contract that
|
||||
/// <see cref="DriverInstanceBootstrapper"/> consumes.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverFactoryRegistryTests
|
||||
{
|
||||
private static IDriver FakeDriver(string id, string config) => new FakeIDriver(id);
|
||||
|
||||
[Fact]
|
||||
public void Register_then_TryGet_returns_factory()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("MyDriver", FakeDriver);
|
||||
|
||||
r.TryGet("MyDriver").ShouldNotBeNull();
|
||||
r.TryGet("Nope").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_is_case_insensitive()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("Galaxy", FakeDriver);
|
||||
r.TryGet("galaxy").ShouldNotBeNull();
|
||||
r.TryGet("GALAXY").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_duplicate_type_throws()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("Galaxy", FakeDriver);
|
||||
Should.Throw<InvalidOperationException>(() => r.Register("Galaxy", FakeDriver));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_null_args_rejected()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
Should.Throw<ArgumentException>(() => r.Register("", FakeDriver));
|
||||
Should.Throw<ArgumentNullException>(() => r.Register("X", null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredTypes_returns_snapshot()
|
||||
{
|
||||
var r = new DriverFactoryRegistry();
|
||||
r.Register("A", FakeDriver);
|
||||
r.Register("B", FakeDriver);
|
||||
r.RegisteredTypes.ShouldContain("A");
|
||||
r.RegisteredTypes.ShouldContain("B");
|
||||
}
|
||||
|
||||
private sealed class FakeIDriver(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "Fake";
|
||||
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-006 — synchronous OnReadValue / OnWriteValue stack hooks must
|
||||
/// derive a <see cref="CancellationToken"/> from the operation deadline so a stalled
|
||||
/// driver call doesn't pin a request thread for the full pipeline timeout. The shared
|
||||
/// helper <see cref="DriverNodeManager.DeriveOperationCancellation"/> turns the
|
||||
/// <see cref="ISystemContext"/>'s <c>OperationDeadline</c> into a linked CTS.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverNodeManagerCancellationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a SystemContext bound to the supplied IOperationContext. SystemContext's
|
||||
/// OperationContext setter is protected, so we use the public <c>Copy</c> method
|
||||
/// which clones the context onto the supplied operation context.
|
||||
/// </summary>
|
||||
private static ISystemContext ContextWithDeadline(DateTime deadline)
|
||||
=> new SystemContext().Copy(new StubOperationContext(deadline));
|
||||
|
||||
[Fact]
|
||||
public void Future_deadline_produces_uncancelled_token()
|
||||
{
|
||||
var ctx = ContextWithDeadline(DateTime.UtcNow.AddSeconds(30));
|
||||
|
||||
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(10));
|
||||
|
||||
cts.Token.IsCancellationRequested.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Past_deadline_produces_already_cancelled_token()
|
||||
{
|
||||
var ctx = ContextWithDeadline(DateTime.UtcNow.AddSeconds(-5));
|
||||
|
||||
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(10));
|
||||
|
||||
cts.Token.IsCancellationRequested.ShouldBeTrue(
|
||||
"an expired OperationDeadline must surface as an immediately-cancelled token so the "
|
||||
+ "stalled driver call returns without burning a request thread");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_deadline_uses_fallback_timeout()
|
||||
{
|
||||
// No OperationContext attached → no deadline plumbed; helper falls back to the
|
||||
// supplied timeout so an OnReadValue hook into a stalled driver can't hang the
|
||||
// request thread indefinitely.
|
||||
var ctx = new SystemContext();
|
||||
|
||||
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromMilliseconds(20));
|
||||
|
||||
cts.Token.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)).ShouldBeTrue(
|
||||
"fallback timeout must fire so a missing deadline cannot hang the request thread");
|
||||
cts.Token.IsCancellationRequested.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_MinValue_deadline_uses_fallback_timeout()
|
||||
{
|
||||
// IOperationContext.OperationDeadline is `DateTime.MinValue` when the stack hasn't
|
||||
// plumbed a deadline through. The helper treats that as "no deadline" and falls
|
||||
// back to the supplied timeout, otherwise an MinValue would surface as
|
||||
// already-cancelled and short-circuit every read.
|
||||
var ctx = ContextWithDeadline(DateTime.MinValue);
|
||||
|
||||
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromMilliseconds(20));
|
||||
|
||||
cts.Token.WaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)).ShouldBeTrue();
|
||||
cts.Token.IsCancellationRequested.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_MaxValue_deadline_uses_fallback_timeout_not_overflow()
|
||||
{
|
||||
// OperationContext sets OperationDeadline = DateTime.MaxValue when the client's
|
||||
// RequestHeader.TimeoutHint is zero (the default). DateTime.MaxValue - UtcNow
|
||||
// overflows CancellationTokenSource(TimeSpan)'s Int32.MaxValue-ms cap, so the
|
||||
// helper must collapse it to the fallback — otherwise the read throws
|
||||
// ArgumentOutOfRangeException from inside DeriveOperationCancellation and surfaces
|
||||
// as BadInternalError on every read (regression that broke OpcUaServerIntegrationTests).
|
||||
var ctx = ContextWithDeadline(DateTime.MaxValue);
|
||||
|
||||
using var cts = DriverNodeManager.DeriveOperationCancellation(ctx, fallback: TimeSpan.FromSeconds(30));
|
||||
|
||||
cts.Token.IsCancellationRequested.ShouldBeFalse("MaxValue deadline + 30 s fallback must produce a live token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_context_returns_uncancelled_token_with_fallback()
|
||||
{
|
||||
// Defensive — OnReadValue receives an ISystemContext from the stack so the helper
|
||||
// shouldn't NRE if a future override passes through a null context.
|
||||
using var cts = DriverNodeManager.DeriveOperationCancellation(context: null!, fallback: TimeSpan.FromSeconds(30));
|
||||
|
||||
cts.Token.IsCancellationRequested.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Minimal IOperationContext for deadline testing.</summary>
|
||||
private sealed class StubOperationContext(DateTime deadline) : IOperationContext
|
||||
{
|
||||
public DateTime OperationDeadline { get; } = deadline;
|
||||
public NodeId? SessionId => null;
|
||||
public IUserIdentity? UserIdentity => null;
|
||||
public IList<string>? PreferredLocales => null;
|
||||
public DiagnosticsMasks DiagnosticsMask => DiagnosticsMasks.None;
|
||||
public StringTable StringTable { get; } = new StringTable();
|
||||
public StatusCode OperationStatus => StatusCodes.Good;
|
||||
public string? AuditEntryId => null;
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the static helpers <see cref="DriverNodeManager"/> exposes to bridge
|
||||
/// driver-side history data (<see cref="HistoricalEvent"/> + <see cref="DataValueSnapshot"/>)
|
||||
/// to the OPC UA on-wire shape (<c>HistoryData</c> / <c>HistoryEvent</c> wrapped in an
|
||||
/// <see cref="ExtensionObject"/>). Fast, framework-only — no server fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverNodeManagerHistoryMappingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(nameof(HistoryAggregateType.Average), HistoryAggregateType.Average)]
|
||||
[InlineData(nameof(HistoryAggregateType.Minimum), HistoryAggregateType.Minimum)]
|
||||
[InlineData(nameof(HistoryAggregateType.Maximum), HistoryAggregateType.Maximum)]
|
||||
[InlineData(nameof(HistoryAggregateType.Total), HistoryAggregateType.Total)]
|
||||
[InlineData(nameof(HistoryAggregateType.Count), HistoryAggregateType.Count)]
|
||||
public void MapAggregate_translates_each_supported_OPC_UA_aggregate_NodeId(
|
||||
string name, HistoryAggregateType expected)
|
||||
{
|
||||
// Resolve the ObjectIds.AggregateFunction_<name> constant via reflection so the test
|
||||
// keeps working if the stack ever renames them — failure means the stack broke its
|
||||
// naming convention, worth surfacing loudly.
|
||||
var field = typeof(ObjectIds).GetField("AggregateFunction_" + name);
|
||||
field.ShouldNotBeNull();
|
||||
var nodeId = (NodeId)field!.GetValue(null)!;
|
||||
|
||||
DriverNodeManager.MapAggregate(nodeId).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregate_returns_null_for_unknown_aggregate()
|
||||
{
|
||||
// AggregateFunction_TimeAverage is a valid OPC UA aggregate but not one the driver
|
||||
// surfaces. Null here means the service handler will translate to BadAggregateNotSupported
|
||||
// — the right behavior per Part 13 when the requested aggregate isn't implemented.
|
||||
DriverNodeManager.MapAggregate(ObjectIds.AggregateFunction_TimeAverage).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapAggregate_returns_null_for_null_input()
|
||||
{
|
||||
// Processed requests that omit the aggregate list (or pass a single null) must not crash.
|
||||
DriverNodeManager.MapAggregate(null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryData_wraps_samples_as_HistoryData_extension_object()
|
||||
{
|
||||
var samples = new[]
|
||||
{
|
||||
new DataValueSnapshot(Value: 42, StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 1, DateTimeKind.Utc)),
|
||||
new DataValueSnapshot(Value: 99, StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 5, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 6, DateTimeKind.Utc)),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryData(samples);
|
||||
|
||||
ext.Body.ShouldBeOfType<HistoryData>();
|
||||
var hd = (HistoryData)ext.Body;
|
||||
hd.DataValues.Count.ShouldBe(2);
|
||||
hd.DataValues[0].Value.ShouldBe(42);
|
||||
hd.DataValues[1].Value.ShouldBe(99);
|
||||
hd.DataValues[0].SourceTimestamp.ShouldBe(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryEvent_wraps_events_with_BaseEventType_field_ordering()
|
||||
{
|
||||
// BuildHistoryEvent populates a fixed field set in BaseEventType's conventional order:
|
||||
// EventId, SourceName, Message, Severity, Time, ReceiveTime. Pinning this so a later
|
||||
// "respect the client's SelectClauses" change can't silently break older clients that
|
||||
// rely on the default layout.
|
||||
var events = new[]
|
||||
{
|
||||
new HistoricalEvent(
|
||||
EventId: "e-1",
|
||||
SourceName: "Tank1.HiAlarm",
|
||||
EventTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc),
|
||||
ReceivedTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc),
|
||||
Message: "High level reached",
|
||||
Severity: 750),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryEvent(events);
|
||||
|
||||
ext.Body.ShouldBeOfType<HistoryEvent>();
|
||||
var he = (HistoryEvent)ext.Body;
|
||||
he.Events.Count.ShouldBe(1);
|
||||
var fields = he.Events[0].EventFields;
|
||||
fields.Count.ShouldBe(6);
|
||||
fields[0].Value.ShouldBe("e-1"); // EventId
|
||||
fields[1].Value.ShouldBe("Tank1.HiAlarm"); // SourceName
|
||||
((LocalizedText)fields[2].Value).Text.ShouldBe("High level reached"); // Message
|
||||
fields[3].Value.ShouldBe((ushort)750); // Severity
|
||||
((DateTime)fields[4].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc));
|
||||
((DateTime)fields[5].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildHistoryEvent_substitutes_empty_string_for_null_SourceName_and_Message()
|
||||
{
|
||||
// Driver-side nulls are preserved through the wire contract by design (distinguishes
|
||||
// "system event with no source" from "source unknown"), but OPC UA Variants of type
|
||||
// String must not carry null — the stack serializes null-string as empty. This test
|
||||
// pins the choice so a nullable-Variant refactor doesn't break clients that display
|
||||
// the field without a null check.
|
||||
var events = new[]
|
||||
{
|
||||
new HistoricalEvent("sys", null, DateTime.UtcNow, DateTime.UtcNow, null, 1),
|
||||
};
|
||||
|
||||
var ext = DriverNodeManager.BuildHistoryEvent(events);
|
||||
var fields = ((HistoryEvent)ext.Body).Events[0].EventFields;
|
||||
fields[1].Value.ShouldBe(string.Empty);
|
||||
((LocalizedText)fields[2].Value).Text.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDataValue_preserves_status_code_and_timestamps()
|
||||
{
|
||||
var snap = new DataValueSnapshot(
|
||||
Value: 123.45,
|
||||
StatusCode: StatusCodes.UncertainSubstituteValue,
|
||||
SourceTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc),
|
||||
ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
var dv = DriverNodeManager.ToDataValue(snap);
|
||||
|
||||
dv.Value.ShouldBe(123.45);
|
||||
dv.StatusCode.Code.ShouldBe(StatusCodes.UncertainSubstituteValue);
|
||||
dv.SourceTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc));
|
||||
dv.ServerTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteNodeIdUnknown_returns_BadNodeIdUnknown_without_unbounded_recursion()
|
||||
{
|
||||
// Regression for Server-001: WriteNodeIdUnknown previously called itself unconditionally
|
||||
// as its first statement — unbounded recursion with no base case → StackOverflowException,
|
||||
// an uncatchable crash of the whole server process. A HistoryRead targeting an
|
||||
// unresolvable NodeId reaches this helper (HistoryReadRawModified / HistoryReadProcessed /
|
||||
// HistoryReadAtTime all call it when ResolveFullRef yields null), so the bug was a
|
||||
// remotely-triggerable DoS. The helper must instead just populate the result + error
|
||||
// slots with BadNodeIdUnknown, mirroring WriteUnsupported / WriteInternalError.
|
||||
//
|
||||
// The call runs on a worker thread with a deliberately small (256 KiB) stack: if the
|
||||
// self-recursion ever returns, the StackOverflowException tears down only that thread's
|
||||
// worker rather than crashing the test host, and the join below times out instead.
|
||||
var results = new HistoryReadResultCollection { new() };
|
||||
var errors = new List<ServiceResult> { ServiceResult.Good };
|
||||
|
||||
var completed = false;
|
||||
var worker = new Thread(() =>
|
||||
{
|
||||
DriverNodeManager.WriteNodeIdUnknown(results, errors, 0);
|
||||
completed = true;
|
||||
}, maxStackSize: 256 * 1024);
|
||||
worker.IsBackground = true;
|
||||
worker.Start();
|
||||
worker.Join(TimeSpan.FromSeconds(5));
|
||||
|
||||
completed.ShouldBeTrue("WriteNodeIdUnknown must return promptly, not recurse until the stack overflows");
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDataValue_leaves_SourceTimestamp_default_when_snapshot_has_no_source_time()
|
||||
{
|
||||
// Galaxy's raw-history rows often carry only a ServerTimestamp (the historian knows
|
||||
// when it wrote the row, not when the process sampled it). The mapping must not
|
||||
// synthesize a bogus SourceTimestamp from ServerTimestamp — that would lie to the
|
||||
// client about the measurement's actual time.
|
||||
var snap = new DataValueSnapshot(Value: 1, StatusCode: 0,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
|
||||
|
||||
var dv = DriverNodeManager.ToDataValue(snap);
|
||||
dv.SourceTimestamp.ShouldBe(default);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 Stream G follow-up — verifies the NodeSourceKind dispatch kernel that
|
||||
/// DriverNodeManager's OnReadValue + OnWriteValue use to route per-node calls to
|
||||
/// the right backend per ADR-002. Pure functions; no OPC UA stack required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverNodeManagerSourceDispatchTests
|
||||
{
|
||||
private sealed class FakeReadable : IReadable
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<DataValueSnapshot>>([]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_source_routes_to_driver_readable()
|
||||
{
|
||||
var drv = new FakeReadable { Name = "drv" };
|
||||
var vt = new FakeReadable { Name = "vt" };
|
||||
var al = new FakeReadable { Name = "al" };
|
||||
|
||||
DriverNodeManager.SelectReadable(NodeSourceKind.Driver, drv, vt, al).ShouldBeSameAs(drv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Virtual_source_routes_to_virtual_readable()
|
||||
{
|
||||
var drv = new FakeReadable();
|
||||
var vt = new FakeReadable();
|
||||
var al = new FakeReadable();
|
||||
|
||||
DriverNodeManager.SelectReadable(NodeSourceKind.Virtual, drv, vt, al).ShouldBeSameAs(vt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_source_routes_to_alarm_readable()
|
||||
{
|
||||
var drv = new FakeReadable();
|
||||
var vt = new FakeReadable();
|
||||
var al = new FakeReadable();
|
||||
|
||||
DriverNodeManager.SelectReadable(NodeSourceKind.ScriptedAlarm, drv, vt, al).ShouldBeSameAs(al);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Virtual_source_without_virtual_readable_returns_null()
|
||||
{
|
||||
// Engine not wired → dispatch layer surfaces BadNotFound (the null propagates
|
||||
// through to the OnReadValue null-check).
|
||||
DriverNodeManager.SelectReadable(
|
||||
NodeSourceKind.Virtual, driverReadable: new FakeReadable(),
|
||||
virtualReadable: null, scriptedAlarmReadable: null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_source_without_alarm_readable_returns_null()
|
||||
{
|
||||
DriverNodeManager.SelectReadable(
|
||||
NodeSourceKind.ScriptedAlarm, driverReadable: new FakeReadable(),
|
||||
virtualReadable: new FakeReadable(), scriptedAlarmReadable: null).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_source_without_driver_readable_returns_null()
|
||||
{
|
||||
// Pre-existing BadNotReadable behavior — unchanged by Phase 7 wiring.
|
||||
DriverNodeManager.SelectReadable(
|
||||
NodeSourceKind.Driver, driverReadable: null,
|
||||
virtualReadable: new FakeReadable(), scriptedAlarmReadable: new FakeReadable()).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWriteAllowedBySource_only_Driver_returns_true()
|
||||
{
|
||||
// Plan decision #6 — OPC UA writes to virtual tags / scripted alarms rejected.
|
||||
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.Driver).ShouldBeTrue();
|
||||
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.Virtual).ShouldBeFalse();
|
||||
DriverNodeManager.IsWriteAllowedBySource(NodeSourceKind.ScriptedAlarm).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end authz regression test for the ADR-001 Task B close-out of task #195.
|
||||
/// Walks the full dispatch flow for a read against an Equipment / Identification
|
||||
/// property: ScopePathIndexBuilder → NodeScopeResolver → AuthorizationGate → PermissionTrie.
|
||||
/// Proves the contract the IdentificationFolderBuilder docstring promises — a user
|
||||
/// without the Equipment-scope grant gets denied on the Identification sub-folder the
|
||||
/// same way they would be denied on the Equipment node itself, because they share the
|
||||
/// Equipment ScopeId (no new scope level for Identification per the builder's remark
|
||||
/// section).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentIdentificationAuthzTests
|
||||
{
|
||||
private const string Cluster = "c-warsaw";
|
||||
private const string Namespace = "ns-plc";
|
||||
|
||||
[Fact]
|
||||
public void Authorized_Group_Read_Granted_On_Identification_Property()
|
||||
{
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unauthorized_Group_Read_Denied_On_Identification_Property()
|
||||
{
|
||||
// The contract in task #195 + the IdentificationFolderBuilder docstring: "a user
|
||||
// without the grant gets BadUserAccessDenied on both the Equipment node + its
|
||||
// Identification variables." This test proves the evaluator side of that contract;
|
||||
// the BadUserAccessDenied surfacing happens in the DriverNodeManager dispatch that
|
||||
// already wires AuthorizationGate.IsAllowed → StatusCodes.BadUserAccessDenied.
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
var scope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("bob", ["cn=other-team"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, scope).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equipment_Grant_Cascades_To_Its_Identification_Properties()
|
||||
{
|
||||
// Identification properties share their parent Equipment's ScopeId (no new scope
|
||||
// level). An Equipment-scope grant must therefore read both — the Equipment's tag
|
||||
// AND its Identification properties — via the same evaluator call path.
|
||||
var (gate, resolver) = BuildEvaluator(equipmentGrantGroup: "cn=line-a-operators");
|
||||
|
||||
var tagScope = resolver.Resolve("plcaddr-temperature");
|
||||
var identityScope = resolver.Resolve("plcaddr-manufacturer");
|
||||
|
||||
var identity = new FakeIdentity("alice", ["cn=line-a-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, tagScope).ShouldBeTrue();
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, identityScope).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_Equipment_Grant_Does_Not_Leak_Across_Equipment_Boundary()
|
||||
{
|
||||
// Grant on oven-3; test reading a tag on press-7 (different equipment). Must deny
|
||||
// so per-Equipment isolation holds at the dispatch layer — the ADR-001 Task B
|
||||
// motivation for populating the full UNS path at resolve time.
|
||||
var (gate, resolver) = BuildEvaluator(
|
||||
equipmentGrantGroup: "cn=oven-3-operators",
|
||||
equipmentIdForGrant: "eq-oven-3");
|
||||
|
||||
var pressScope = resolver.Resolve("plcaddr-press-7-temp"); // belongs to eq-press-7
|
||||
|
||||
var identity = new FakeIdentity("charlie", ["cn=oven-3-operators"]);
|
||||
gate.IsAllowed(identity, OpcUaOperation.Read, pressScope).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- harness -----
|
||||
|
||||
/// <summary>
|
||||
/// Build the AuthorizationGate + NodeScopeResolver pair for a fixture with two
|
||||
/// Equipment rows (oven-3 + press-7) under one UNS line, one NodeAcl grant at
|
||||
/// Equipment scope for <paramref name="equipmentGrantGroup"/>, and a ScopePathIndex
|
||||
/// populated via ScopePathIndexBuilder from the same Config-DB row set the
|
||||
/// EquipmentNodeWalker would consume at address-space build.
|
||||
/// </summary>
|
||||
private static (AuthorizationGate Gate, NodeScopeResolver Resolver) BuildEvaluator(
|
||||
string equipmentGrantGroup,
|
||||
string equipmentIdForGrant = "eq-oven-3")
|
||||
{
|
||||
var (content, scopeIndex) = BuildFixture();
|
||||
var resolver = new NodeScopeResolver(Cluster, scopeIndex);
|
||||
|
||||
var aclRow = new NodeAcl
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = Cluster,
|
||||
LdapGroup = equipmentGrantGroup,
|
||||
ScopeKind = NodeAclScopeKind.Equipment,
|
||||
ScopeId = equipmentIdForGrant,
|
||||
PermissionFlags = NodePermissions.Browse | NodePermissions.Read,
|
||||
};
|
||||
var paths = new Dictionary<string, NodeAclPath>
|
||||
{
|
||||
[equipmentIdForGrant] = new NodeAclPath(new[] { Namespace, "area-1", "line-a", equipmentIdForGrant }),
|
||||
};
|
||||
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build(Cluster, 1, [aclRow], paths));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
var gate = new AuthorizationGate(evaluator, strictMode: true, trieCache: cache);
|
||||
|
||||
_ = content;
|
||||
return (gate, resolver);
|
||||
}
|
||||
|
||||
private static (EquipmentNamespaceContent, IReadOnlyDictionary<string, NodeScope>) BuildFixture()
|
||||
{
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = Cluster, Name = "warsaw", GenerationId = 1 };
|
||||
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
||||
|
||||
var oven = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "oven-3",
|
||||
MachineCode = "MC-oven-3", Manufacturer = "Trumpf",
|
||||
};
|
||||
var press = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = "drv", UnsLineId = "line-a", Name = "press-7",
|
||||
MachineCode = "MC-press-7",
|
||||
};
|
||||
|
||||
// Two tags for oven-3, one for press-7. Use Tag.TagConfig as the driver-side full
|
||||
// reference the dispatch layer passes to NodeScopeResolver.Resolve.
|
||||
var tempTag = NewTag("tag-1", "Temperature", "Int32", "plcaddr-temperature", "eq-oven-3");
|
||||
var mfgTag = NewTag("tag-2", "Manufacturer", "String", "plcaddr-manufacturer", "eq-oven-3");
|
||||
var pressTempTag = NewTag("tag-3", "PressTemp", "Int32", "plcaddr-press-7-temp", "eq-press-7");
|
||||
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [area],
|
||||
Lines: [line],
|
||||
Equipment: [oven, press],
|
||||
Tags: [tempTag, mfgTag, pressTempTag]);
|
||||
|
||||
var index = ScopePathIndexBuilder.Build(Cluster, Namespace, content);
|
||||
return (content, index);
|
||||
}
|
||||
|
||||
private static Tag NewTag(string tagId, string name, string dataType, string address, string equipmentId) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = tagId,
|
||||
DriverInstanceId = "drv", EquipmentId = equipmentId, Name = name,
|
||||
DataType = dataType, AccessLevel = TagAccessLevel.ReadWrite, TagConfig = address,
|
||||
};
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class EquipmentNamespaceContentLoaderTests : IDisposable
|
||||
{
|
||||
private const string DriverId = "galaxy-prod";
|
||||
private const string OtherDriverId = "galaxy-dev";
|
||||
private const long Gen = 5;
|
||||
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly EquipmentNamespaceContentLoader _loader;
|
||||
|
||||
public EquipmentNamespaceContentLoaderTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"eq-content-loader-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
_loader = new EquipmentNamespaceContentLoader(_db);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_Null_When_Driver_Has_No_Equipment_At_Generation()
|
||||
{
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Loads_Areas_Lines_Equipment_Tags_For_Driver_At_Generation()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Areas.ShouldHaveSingleItem().UnsAreaId.ShouldBe("area-1");
|
||||
result.Lines.ShouldHaveSingleItem().UnsLineId.ShouldBe("line-a");
|
||||
result.Equipment.Count.ShouldBe(2);
|
||||
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-oven-3");
|
||||
result.Equipment.ShouldContain(e => e.EquipmentId == "eq-press-7");
|
||||
result.Tags.Count.ShouldBe(2);
|
||||
result.Tags.ShouldContain(t => t.TagId == "tag-temp");
|
||||
result.Tags.ShouldContain(t => t.TagId == "tag-press");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_Other_Drivers_Equipment()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
// Equipment + Tag owned by a different driver at the same generation — must not leak.
|
||||
_db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-other", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = OtherDriverId, UnsLineId = "line-a", Name = "other-eq",
|
||||
MachineCode = "MC-other",
|
||||
});
|
||||
_db.Tags.Add(new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-other",
|
||||
DriverInstanceId = OtherDriverId, EquipmentId = "eq-other",
|
||||
Name = "OtherTag", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-other",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-other");
|
||||
result.Tags.ShouldNotContain(t => t.TagId == "tag-other");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_Other_Generations()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
// Same driver, different generation — must not leak in. Walker consumes one sealed
|
||||
// generation per bootstrap per decision #148.
|
||||
_db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 99,
|
||||
EquipmentId = "eq-futuristic", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "futuristic",
|
||||
MachineCode = "MC-fut",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-futuristic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_Disabled_Equipment()
|
||||
{
|
||||
SeedBaseline();
|
||||
|
||||
_db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-disabled", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "disabled-eq",
|
||||
MachineCode = "MC-dis", Enabled = false,
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var result = await _loader.LoadAsync(DriverId, Gen, CancellationToken.None);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Equipment.ShouldNotContain(e => e.EquipmentId == "eq-disabled");
|
||||
}
|
||||
|
||||
private void SeedBaseline()
|
||||
{
|
||||
_db.UnsAreas.Add(new UnsArea
|
||||
{
|
||||
UnsAreaRowId = Guid.NewGuid(), UnsAreaId = "area-1", ClusterId = "c-warsaw",
|
||||
Name = "warsaw", GenerationId = Gen,
|
||||
});
|
||||
_db.UnsLines.Add(new UnsLine
|
||||
{
|
||||
UnsLineRowId = Guid.NewGuid(), UnsLineId = "line-a", UnsAreaId = "area-1",
|
||||
Name = "line-a", GenerationId = Gen,
|
||||
});
|
||||
_db.Equipment.AddRange(
|
||||
new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
|
||||
MachineCode = "MC-oven-3",
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = Gen,
|
||||
EquipmentId = "eq-press-7", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "press-7",
|
||||
MachineCode = "MC-press-7",
|
||||
});
|
||||
_db.Tags.AddRange(
|
||||
new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-temp",
|
||||
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
|
||||
Name = "Temperature", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
|
||||
},
|
||||
new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = Gen, TagId = "tag-press",
|
||||
DriverInstanceId = DriverId, EquipmentId = "eq-press-7",
|
||||
Name = "PressTemp", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-press-temp",
|
||||
});
|
||||
_db.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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.Server.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="GenerationRefreshHostedService"/>. Exercises the
|
||||
/// lease-around-refresh semantics via a stub generation-query delegate — the real
|
||||
/// DB path is exercised end-to-end by the Phase 6.3 compliance script.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class GenerationRefreshHostedServiceTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
|
||||
public GenerationRefreshHostedServiceTests()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"gen-refresh-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(opts);
|
||||
_dbFactory = new DbContextFactory(opts);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task First_tick_applies_current_generation_and_closes_the_lease()
|
||||
{
|
||||
var coordinator = await SeedCoordinatorAsync();
|
||||
var leases = new ApplyLeaseRegistry();
|
||||
var service = NewService(coordinator, leases, currentGeneration: () => 42);
|
||||
|
||||
leases.IsApplyInProgress.ShouldBeFalse("no lease before first tick");
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
service.LastAppliedGenerationId.ShouldBe(42);
|
||||
service.TickCount.ShouldBe(1);
|
||||
service.RefreshCount.ShouldBe(1);
|
||||
leases.IsApplyInProgress.ShouldBeFalse("lease must be disposed after the apply window");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subsequent_tick_with_same_generation_is_a_no_op()
|
||||
{
|
||||
var coordinator = await SeedCoordinatorAsync();
|
||||
var leases = new ApplyLeaseRegistry();
|
||||
var service = NewService(coordinator, leases, currentGeneration: () => 42);
|
||||
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
service.TickCount.ShouldBe(2);
|
||||
service.RefreshCount.ShouldBe(1, "second identical tick must skip the refresh");
|
||||
leases.IsApplyInProgress.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generation_change_triggers_new_refresh()
|
||||
{
|
||||
var coordinator = await SeedCoordinatorAsync();
|
||||
var leases = new ApplyLeaseRegistry();
|
||||
var current = 42L;
|
||||
var service = NewService(coordinator, leases, currentGeneration: () => current);
|
||||
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
current = 43L;
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
service.LastAppliedGenerationId.ShouldBe(43);
|
||||
service.RefreshCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_generation_means_no_published_config_yet_and_does_not_apply()
|
||||
{
|
||||
var coordinator = await SeedCoordinatorAsync();
|
||||
var leases = new ApplyLeaseRegistry();
|
||||
var service = NewService(coordinator, leases, currentGeneration: () => null);
|
||||
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
service.LastAppliedGenerationId.ShouldBeNull();
|
||||
service.RefreshCount.ShouldBe(0);
|
||||
service.TickCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lease_is_opened_during_the_refresh_window()
|
||||
{
|
||||
// Drive a query delegate that *also* observes lease state mid-call: the delegate
|
||||
// fires before BeginApplyLease, so it sees IsApplyInProgress=false here, not
|
||||
// during the lease window. We observe the lease from the outside by checking
|
||||
// OpenLeaseCount on completion — if the `await using` mis-disposed we'd see 1
|
||||
// dangling. Cleanest assertion in a stub-only world.
|
||||
var coordinator = await SeedCoordinatorAsync();
|
||||
var leases = new ApplyLeaseRegistry();
|
||||
var service = NewService(coordinator, leases, currentGeneration: () => 42);
|
||||
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
leases.OpenLeaseCount.ShouldBe(0, "IAsyncDisposable dispose must fire regardless of outcome");
|
||||
}
|
||||
|
||||
// ---- fixture helpers ---------------------------------------------------
|
||||
|
||||
private async Task<RedundancyCoordinator> SeedCoordinatorAsync()
|
||||
{
|
||||
_db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "c1", Name = "W", Enterprise = "zb", Site = "w",
|
||||
RedundancyMode = RedundancyMode.None, CreatedBy = "test",
|
||||
});
|
||||
_db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = "A", ClusterId = "c1",
|
||||
RedundancyRole = RedundancyRole.Primary, Host = "a",
|
||||
ApplicationUri = "urn:A", CreatedBy = "test",
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var coordinator = new RedundancyCoordinator(
|
||||
_dbFactory, NullLogger<RedundancyCoordinator>.Instance, "A", "c1");
|
||||
await coordinator.InitializeAsync(CancellationToken.None);
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
private static GenerationRefreshHostedService NewService(
|
||||
RedundancyCoordinator coordinator,
|
||||
ApplyLeaseRegistry leases,
|
||||
Func<long?> currentGeneration) =>
|
||||
new(new NodeOptions { NodeId = "A", ClusterId = "c1", ConfigDbConnectionString = "unused" },
|
||||
leases, coordinator, NullLogger<GenerationRefreshHostedService>.Instance,
|
||||
tickInterval: TimeSpan.FromSeconds(1),
|
||||
currentGenerationQuery: _ => Task.FromResult(currentGeneration()));
|
||||
|
||||
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||
: IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => new(options);
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Observability;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HealthEndpointsHostTests : IAsyncLifetime
|
||||
{
|
||||
private static int _portCounter = 48500 + Random.Shared.Next(0, 99);
|
||||
private readonly int _port = Interlocked.Increment(ref _portCounter);
|
||||
private string Prefix => $"http://localhost:{_port}/";
|
||||
private readonly DriverHost _driverHost = new();
|
||||
private HealthEndpointsHost _host = null!;
|
||||
private HttpClient _client = null!;
|
||||
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
_client = new HttpClient { BaseAddress = new Uri(Prefix) };
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
}
|
||||
|
||||
private HealthEndpointsHost Start(Func<bool>? configDbHealthy = null, Func<bool>? usingStaleConfig = null)
|
||||
{
|
||||
_host = new HealthEndpointsHost(
|
||||
_driverHost,
|
||||
NullLogger<HealthEndpointsHost>.Instance,
|
||||
configDbHealthy,
|
||||
usingStaleConfig,
|
||||
prefix: Prefix);
|
||||
_host.Start();
|
||||
return _host;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Healthz_ReturnsHealthy_EmptyFleet()
|
||||
{
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/healthz");
|
||||
|
||||
response.IsSuccessStatusCode.ShouldBeTrue();
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("status").GetString().ShouldBe("healthy");
|
||||
body.GetProperty("configDbReachable").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Healthz_StaleConfig_Returns200_WithFlag()
|
||||
{
|
||||
Start(configDbHealthy: () => false, usingStaleConfig: () => true);
|
||||
|
||||
var response = await _client.GetAsync("/healthz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("configDbReachable").GetBoolean().ShouldBeFalse();
|
||||
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Healthz_UnreachableConfig_And_NoCache_Returns503()
|
||||
{
|
||||
Start(configDbHealthy: () => false, usingStaleConfig: () => false);
|
||||
|
||||
var response = await _client.GetAsync("/healthz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_EmptyFleet_Is200_Healthy()
|
||||
{
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithHealthyDriver_Is200()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("drv-1", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
|
||||
body.GetProperty("drivers").GetArrayLength().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithFaultedDriver_Is503()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("dead", DriverState.Faulted), "{}", CancellationToken.None);
|
||||
await _driverHost.RegisterAsync(new StubDriver("alive", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Faulted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithDegradedDriver_Is200_WithDegradedList()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("drv-ok", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
await _driverHost.RegisterAsync(new StubDriver("drv-deg", DriverState.Degraded), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("verdict").GetString().ShouldBe("Degraded");
|
||||
body.GetProperty("degradedDrivers").GetArrayLength().ShouldBe(1);
|
||||
body.GetProperty("degradedDrivers")[0].GetString().ShouldBe("drv-deg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readyz_WithInitializingDriver_Is503()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("init", DriverState.Initializing), "{}", CancellationToken.None);
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/readyz");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_Path_Returns404()
|
||||
{
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/foo");
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
// ===== #154 — driver-diagnostics endpoint =====
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver()
|
||||
{
|
||||
// Bring up a Modbus driver with a programmable transport that protects register 102,
|
||||
// record one prohibition, then hit /diagnostics/drivers/{id}/modbus/auto-prohibited.
|
||||
var fake = new ModbusDriverDiagnosticsTransport { ProtectedAddress = 102 };
|
||||
var t1 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
|
||||
"T1", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 100, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
|
||||
var t2 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
|
||||
"T2", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 102, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
|
||||
var opts = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverOptions
|
||||
{
|
||||
Host = "f", Tags = [t1, t2], MaxReadGap = 5,
|
||||
Probe = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
var driver = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriver(opts, "diag-mb", _ => fake);
|
||||
await _driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
await driver.ReadAsync(["T1", "T2"], CancellationToken.None);
|
||||
|
||||
Start();
|
||||
|
||||
var response = await _client.GetAsync("/diagnostics/drivers/diag-mb/modbus/auto-prohibited");
|
||||
|
||||
response.IsSuccessStatusCode.ShouldBeTrue();
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
|
||||
body.GetProperty("driverInstanceId").GetString().ShouldBe("diag-mb");
|
||||
body.GetProperty("count").GetInt32().ShouldBe(1);
|
||||
var first = body.GetProperty("ranges")[0];
|
||||
first.GetProperty("startAddress").GetInt32().ShouldBe(100);
|
||||
first.GetProperty("endAddress").GetInt32().ShouldBe(102);
|
||||
first.GetProperty("region").GetString().ShouldBe("HoldingRegisters");
|
||||
first.GetProperty("bisectionPending").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostics_404_When_Driver_Not_Found()
|
||||
{
|
||||
Start();
|
||||
var response = await _client.GetAsync("/diagnostics/drivers/no-such/modbus/auto-prohibited");
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostics_400_When_Driver_Is_Wrong_Type()
|
||||
{
|
||||
await _driverHost.RegisterAsync(new StubDriver("not-modbus", DriverState.Healthy), "{}", CancellationToken.None);
|
||||
Start();
|
||||
var response = await _client.GetAsync("/diagnostics/drivers/not-modbus/modbus/auto-prohibited");
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
private sealed class ModbusDriverDiagnosticsTransport : ZB.MOM.WW.OtOpcUa.Driver.Modbus.IModbusTransport
|
||||
{
|
||||
public ushort ProtectedAddress { get; set; } = 102;
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
|
||||
return Task.FromException<byte[]>(new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusException(0x03, 0x02, "IllegalDataAddress"));
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StubDriver : IDriver
|
||||
{
|
||||
private readonly DriverState _state;
|
||||
public StubDriver(string id, DriverState state)
|
||||
{
|
||||
DriverInstanceId = id;
|
||||
_state = state;
|
||||
}
|
||||
public string DriverInstanceId { get; }
|
||||
public string DriverType => "Stub";
|
||||
public Task InitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(_state, null, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.History;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HistoryRouter"/> registration + resolution semantics added
|
||||
/// in PR 1.2. The router is the only seam between OPC UA HistoryRead service calls
|
||||
/// and registered <see cref="IHistorianDataSource"/> implementations, so the
|
||||
/// resolution rules (case-insensitive prefix, longest-match wins, no source =>
|
||||
/// null) need explicit coverage.
|
||||
/// </summary>
|
||||
public sealed class HistoryRouterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_ReturnsNull_WhenNoSourceRegistered()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
router.Resolve("anything").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ReturnsRegisteredSource_WhenPrefixMatches()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var source = new FakeSource("galaxy");
|
||||
router.Register("galaxy", source);
|
||||
|
||||
router.Resolve("galaxy.TankFarm.Tank1.Level").ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_ReturnsNull_WhenPrefixDoesNotMatch()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
router.Register("galaxy", new FakeSource("galaxy"));
|
||||
|
||||
router.Resolve("modbus.MyDevice.Tag1").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_LongestPrefixWins_WhenMultipleRegistered()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var generic = new FakeSource("generic");
|
||||
var specific = new FakeSource("specific");
|
||||
|
||||
router.Register("galaxy", generic);
|
||||
router.Register("galaxy.HighRate", specific);
|
||||
|
||||
router.Resolve("galaxy.HighRate.Sensor1").ShouldBe(specific);
|
||||
router.Resolve("galaxy.LowRate.Sensor2").ShouldBe(generic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_IsCaseInsensitive_OnPrefixMatch()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var source = new FakeSource("galaxy");
|
||||
router.Register("Galaxy", source);
|
||||
|
||||
router.Resolve("galaxy.foo").ShouldBe(source);
|
||||
router.Resolve("GALAXY.foo").ShouldBe(source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_Throws_WhenPrefixAlreadyRegistered()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
router.Register("galaxy", new FakeSource("first"));
|
||||
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => router.Register("galaxy", new FakeSource("second")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DisposesAllRegisteredSources()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
var a = new FakeSource("a");
|
||||
var b = new FakeSource("b");
|
||||
router.Register("ns_a", a);
|
||||
router.Register("ns_b", b);
|
||||
|
||||
router.Dispose();
|
||||
|
||||
a.IsDisposed.ShouldBeTrue();
|
||||
b.IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_SwallowsExceptionsFromMisbehavingSource()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
var throwing = new ThrowingFakeSource();
|
||||
var clean = new FakeSource("clean");
|
||||
router.Register("bad", throwing);
|
||||
router.Register("good", clean);
|
||||
|
||||
// Even when one source's Dispose throws, the router must finish disposing the
|
||||
// remaining sources (server shutdown invariant).
|
||||
Should.NotThrow(() => router.Dispose());
|
||||
clean.IsDisposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Throws_AfterDisposal()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
router.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(() => router.Resolve("anything"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_Throws_AfterDisposal()
|
||||
{
|
||||
var router = new HistoryRouter();
|
||||
router.Dispose();
|
||||
|
||||
Should.Throw<ObjectDisposedException>(
|
||||
() => router.Register("ns", new FakeSource("x")));
|
||||
}
|
||||
|
||||
private sealed class FakeSource(string name) : IHistorianDataSource
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose() => IsDisposed = true;
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
=> new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
|
||||
}
|
||||
|
||||
private sealed class ThrowingFakeSource : IHistorianDataSource
|
||||
{
|
||||
public void Dispose() => throw new InvalidOperationException("boom");
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot()
|
||||
=> new(0, 0, 0, 0, null, null, null, false, false, null, null, []);
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
// Core.Abstractions.HistoryReadResult (driver-side samples) collides with Opc.Ua.HistoryReadResult
|
||||
// (service-layer per-node result). Alias the driver type so the stub's interface implementations
|
||||
// are unambiguous.
|
||||
using DriverHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's
|
||||
/// <see cref="IHistoryProvider"/> via <see cref="DriverNodeManager"/>'s
|
||||
/// <c>HistoryReadRawModified</c> / <c>HistoryReadProcessed</c> / <c>HistoryReadAtTime</c> /
|
||||
/// <c>HistoryReadEvents</c> overrides. Boots the full OPC UA stack + a stub
|
||||
/// <see cref="IHistoryProvider"/> driver, opens a client session, issues each HistoryRead
|
||||
/// variant, and asserts the client receives the expected per-kind payload.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HistoryReadIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48600 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaHistoryTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-history-test-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
private HistoryDriver _driver = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
_driver = new HistoryDriver();
|
||||
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaHistoryTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:HistoryTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true, HealthEndpointsEnabled = false,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadRaw_round_trips_driver_samples_to_the_client()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
// Path-based NodeId per #134 — `{driverId}/{browseName}` since DiscoverAsync registers
|
||||
// variables at the driver root rather than under a folder.
|
||||
var nodeId = new NodeId("history-driver/raw", nsIndex);
|
||||
|
||||
// The Opc.Ua client exposes HistoryRead via Session.HistoryRead. We construct a
|
||||
// ReadRawModifiedDetails (IsReadModified=false → raw path) and a single
|
||||
// HistoryReadValueId targeting the driver-backed variable.
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 0, 10, DateTimeKind.Utc),
|
||||
NumValuesPerNode = 100,
|
||||
IsReadModified = false,
|
||||
ReturnBounds = false,
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good, $"HistoryReadRaw returned {results[0].StatusCode}");
|
||||
var hd = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
hd.DataValues.Count.ShouldBe(_driver.RawSamplesReturned, "one DataValue per driver sample");
|
||||
hd.DataValues[0].Value.ShouldBe(_driver.FirstRawValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadProcessed_maps_Average_aggregate_and_routes_to_ReadProcessedAsync()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("history-driver/proc", nsIndex);
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
ProcessingInterval = 10_000, // 10s buckets
|
||||
AggregateType = [ObjectIds.AggregateFunction_Average],
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
_driver.LastProcessedAggregate.ShouldBe(HistoryAggregateType.Average,
|
||||
"MapAggregate must translate ObjectIds.AggregateFunction_Average → driver enum");
|
||||
_driver.LastProcessedInterval.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadProcessed_returns_BadAggregateNotSupported_for_unmapped_aggregate()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("history-driver/proc", nsIndex);
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
ProcessingInterval = 10_000,
|
||||
// TimeAverage is a valid OPC UA aggregate NodeId but not one the driver implements —
|
||||
// the override returns BadAggregateNotSupported per Part 13 rather than coercing.
|
||||
AggregateType = [ObjectIds.AggregateFunction_TimeAverage],
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadAtTime_forwards_timestamp_list_to_driver()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("history-driver/atTime", nsIndex);
|
||||
|
||||
var t1 = new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc);
|
||||
var t2 = new DateTime(2024, 3, 1, 10, 0, 30, DateTimeKind.Utc);
|
||||
var details = new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t1, t2 } };
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
_driver.LastAtTimeRequestedTimes.ShouldNotBeNull();
|
||||
_driver.LastAtTimeRequestedTimes!.Count.ShouldBe(2);
|
||||
_driver.LastAtTimeRequestedTimes[0].ShouldBe(t1);
|
||||
_driver.LastAtTimeRequestedTimes[1].ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryReadEvents_returns_HistoryEvent_with_BaseEventType_field_list()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
// Events target the driver-root notifier (not a specific variable) which is the
|
||||
// conventional pattern for alarm-history browse.
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver");
|
||||
var nodeId = new NodeId("history-driver", nsIndex);
|
||||
|
||||
// EventFilter must carry at least one SelectClause or the stack rejects it as
|
||||
// BadEventFilterInvalid before our override runs — empty filters are spec-forbidden.
|
||||
// We populate the standard BaseEventType selectors any real client would send; my
|
||||
// override's BuildHistoryEvent ignores the specific clauses and emits the canonical
|
||||
// field list anyway (the richer "respect exact SelectClauses" behavior is on the PR 38
|
||||
// follow-up list).
|
||||
var filter = new EventFilter();
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time);
|
||||
filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.ReceiveTime);
|
||||
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
EndTime = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
NumValuesPerNode = 10,
|
||||
Filter = filter,
|
||||
};
|
||||
var extObj = new ExtensionObject(details);
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } };
|
||||
|
||||
session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead,
|
||||
out var results, out _);
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var he = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
he.Events.Count.ShouldBe(_driver.EventsReturned);
|
||||
he.Events[0].EventFields.Count.ShouldBe(6, "BaseEventType default field layout is 6 entries");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaHistoryTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:HistoryTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaHistoryTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaHistoryTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub driver that implements <see cref="IHistoryProvider"/> so the service dispatch
|
||||
/// can be verified without bringing up a real Galaxy or Historian. Captures the last-
|
||||
/// seen arguments so tests can assert what the service handler forwarded.
|
||||
/// </summary>
|
||||
private sealed class HistoryDriver : IDriver, ITagDiscovery, IReadable, IHistoryProvider
|
||||
{
|
||||
public string DriverInstanceId => "history-driver";
|
||||
public string DriverType => "HistoryStub";
|
||||
|
||||
public int RawSamplesReturned => 3;
|
||||
public int FirstRawValue => 100;
|
||||
public int EventsReturned => 2;
|
||||
|
||||
public HistoryAggregateType? LastProcessedAggregate { get; private set; }
|
||||
public TimeSpan? LastProcessedInterval { get; private set; }
|
||||
public IReadOnlyList<DateTime>? LastAtTimeRequestedTimes { get; private set; }
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
// Every variable must be Historized for HistoryRead to route — the node-manager's
|
||||
// stack base class checks the bit before dispatching.
|
||||
builder.Variable("raw", "raw",
|
||||
new DriverAttributeInfo("raw.var", DriverDataType.Int32, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
builder.Variable("proc", "proc",
|
||||
new DriverAttributeInfo("proc.var", DriverDataType.Float64, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
builder.Variable("atTime", "atTime",
|
||||
new DriverAttributeInfo("atTime.var", DriverDataType.Int32, false, null,
|
||||
SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> r =
|
||||
[.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))];
|
||||
return Task.FromResult(r);
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = new List<DataValueSnapshot>();
|
||||
for (var i = 0; i < RawSamplesReturned; i++)
|
||||
{
|
||||
samples.Add(new DataValueSnapshot(
|
||||
Value: FirstRawValue + i,
|
||||
StatusCode: StatusCodes.Good,
|
||||
SourceTimestampUtc: startUtc.AddSeconds(i),
|
||||
ServerTimestampUtc: startUtc.AddSeconds(i)));
|
||||
}
|
||||
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
LastProcessedAggregate = aggregate;
|
||||
LastProcessedInterval = interval;
|
||||
return Task.FromResult(new DriverHistoryReadResult(
|
||||
[new DataValueSnapshot(1.0, StatusCodes.Good, startUtc, startUtc)],
|
||||
null));
|
||||
}
|
||||
|
||||
public Task<DriverHistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastAtTimeRequestedTimes = timestampsUtc;
|
||||
var samples = timestampsUtc
|
||||
.Select(t => new DataValueSnapshot(42, StatusCodes.Good, t, t))
|
||||
.ToArray();
|
||||
return Task.FromResult(new DriverHistoryReadResult(samples, null));
|
||||
}
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var events = new List<HistoricalEvent>();
|
||||
for (var i = 0; i < EventsReturned; i++)
|
||||
{
|
||||
events.Add(new HistoricalEvent(
|
||||
EventId: $"e{i}",
|
||||
SourceName: sourceName,
|
||||
EventTimeUtc: startUtc.AddHours(i),
|
||||
ReceivedTimeUtc: startUtc.AddHours(i).AddSeconds(1),
|
||||
Message: $"Event {i}",
|
||||
Severity: (ushort)(500 + i)));
|
||||
}
|
||||
return Task.FromResult(new HistoricalEventsResult(events, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class HostStatusPublisherTests : IDisposable
|
||||
{
|
||||
private const string DefaultServer = "10.100.0.35,14330";
|
||||
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
|
||||
|
||||
private readonly string _databaseName = $"OtOpcUaPublisher_{Guid.NewGuid():N}";
|
||||
private readonly string _connectionString;
|
||||
private readonly ServiceProvider _sp;
|
||||
|
||||
public HostStatusPublisherTests()
|
||||
{
|
||||
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
|
||||
var password = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
|
||||
_connectionString =
|
||||
$"Server={server};Database={_databaseName};User Id=sa;Password={password};TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseSqlServer(_connectionString));
|
||||
_sp = services.BuildServiceProvider();
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>().Database.Migrate();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sp.Dispose();
|
||||
using var conn = new Microsoft.Data.SqlClient.SqlConnection(
|
||||
new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(_connectionString) { InitialCatalog = "master" }.ConnectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $@"
|
||||
IF DB_ID(N'{_databaseName}') IS NOT NULL
|
||||
BEGIN
|
||||
ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
||||
DROP DATABASE [{_databaseName}];
|
||||
END";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publisher_upserts_one_row_per_host_reported_by_each_probe_driver()
|
||||
{
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(new ProbeStubDriver("driver-a",
|
||||
new HostConnectivityStatus("HostA1", HostState.Running, DateTime.UtcNow),
|
||||
new HostConnectivityStatus("HostA2", HostState.Stopped, DateTime.UtcNow)),
|
||||
"{}", CancellationToken.None);
|
||||
await driverHost.RegisterAsync(new NonProbeStubDriver("driver-no-probe"), "{}", CancellationToken.None);
|
||||
|
||||
var nodeOptions = NewNodeOptions("node-a");
|
||||
var publisher = new HostStatusPublisher(driverHost, nodeOptions, _sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var rows = await db.DriverHostStatuses.AsNoTracking().ToListAsync();
|
||||
|
||||
rows.Count.ShouldBe(2, "driver-no-probe doesn't implement IHostConnectivityProbe — no rows for it");
|
||||
rows.ShouldContain(r => r.HostName == "HostA1" && r.State == DriverHostState.Running && r.DriverInstanceId == "driver-a");
|
||||
rows.ShouldContain(r => r.HostName == "HostA2" && r.State == DriverHostState.Stopped && r.DriverInstanceId == "driver-a");
|
||||
rows.ShouldAllBe(r => r.NodeId == "node-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_tick_updates_LastSeenUtc_without_creating_duplicate_rows()
|
||||
{
|
||||
var driver = new ProbeStubDriver("driver-x",
|
||||
new HostConnectivityStatus("HostX", HostState.Running, DateTime.UtcNow));
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-x"),
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var firstSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
||||
await Task.Delay(50); // guarantee a later wall-clock value so LastSeenUtc advances
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var secondSeen = await SingleRowAsync("node-x", "driver-x", "HostX");
|
||||
|
||||
secondSeen.LastSeenUtc.ShouldBeGreaterThan(firstSeen.LastSeenUtc,
|
||||
"heartbeat advances LastSeenUtc so Admin can stale-flag rows from crashed Servers");
|
||||
|
||||
// Still exactly one row — a naive Add-every-tick would have thrown or duplicated.
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
(await db.DriverHostStatuses.CountAsync(r => r.NodeId == "node-x")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_change_between_ticks_updates_State_and_StateChangedUtc()
|
||||
{
|
||||
var driver = new ProbeStubDriver("driver-y",
|
||||
new HostConnectivityStatus("HostY", HostState.Running, DateTime.UtcNow.AddSeconds(-10)));
|
||||
var driverHost = new DriverHost();
|
||||
await driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
|
||||
|
||||
var publisher = new HostStatusPublisher(driverHost, NewNodeOptions("node-y"),
|
||||
_sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
NullLogger<HostStatusPublisher>.Instance);
|
||||
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
var before = await SingleRowAsync("node-y", "driver-y", "HostY");
|
||||
|
||||
// Swap the driver's reported state to Faulted with a newer transition timestamp.
|
||||
var newChange = DateTime.UtcNow;
|
||||
driver.Statuses = [new HostConnectivityStatus("HostY", HostState.Faulted, newChange)];
|
||||
await publisher.PublishOnceAsync(CancellationToken.None);
|
||||
|
||||
var after = await SingleRowAsync("node-y", "driver-y", "HostY");
|
||||
after.State.ShouldBe(DriverHostState.Faulted);
|
||||
// datetime2(3) has millisecond precision — DateTime.UtcNow carries up to 100ns ticks,
|
||||
// so the stored value rounds down. Compare at millisecond granularity to stay clean.
|
||||
after.StateChangedUtc.ShouldBe(newChange, tolerance: TimeSpan.FromMilliseconds(1));
|
||||
after.StateChangedUtc.ShouldBeGreaterThan(before.StateChangedUtc,
|
||||
"StateChangedUtc must advance when the state actually changed");
|
||||
before.State.ShouldBe(DriverHostState.Running);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapState_translates_every_HostState_member()
|
||||
{
|
||||
HostStatusPublisher.MapState(HostState.Running).ShouldBe(DriverHostState.Running);
|
||||
HostStatusPublisher.MapState(HostState.Stopped).ShouldBe(DriverHostState.Stopped);
|
||||
HostStatusPublisher.MapState(HostState.Faulted).ShouldBe(DriverHostState.Faulted);
|
||||
HostStatusPublisher.MapState(HostState.Unknown).ShouldBe(DriverHostState.Unknown);
|
||||
}
|
||||
|
||||
private async Task<Configuration.Entities.DriverHostStatus> SingleRowAsync(string node, string driver, string host)
|
||||
{
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
return await db.DriverHostStatuses.AsNoTracking()
|
||||
.SingleAsync(r => r.NodeId == node && r.DriverInstanceId == driver && r.HostName == host);
|
||||
}
|
||||
|
||||
private static NodeOptions NewNodeOptions(string nodeId) => new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
ClusterId = "cluster-t",
|
||||
ConfigDbConnectionString = "unused-publisher-gets-db-from-scope",
|
||||
};
|
||||
|
||||
private sealed class ProbeStubDriver(string id, params HostConnectivityStatus[] initial)
|
||||
: IDriver, IHostConnectivityProbe
|
||||
{
|
||||
public HostConnectivityStatus[] Statuses { get; set; } = initial;
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "ProbeStub";
|
||||
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses() => Statuses;
|
||||
|
||||
// Keeps the compiler happy — event is part of the interface contract even if unused here.
|
||||
internal void Raise(HostStatusChangedEventArgs e) => OnHostStatusChanged?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private sealed class NonProbeStubDriver(string id) : IDriver
|
||||
{
|
||||
public string DriverInstanceId => id;
|
||||
public string DriverType => "NonProbeStub";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapOptionsTests
|
||||
{
|
||||
// Server-009 regression: the out-of-the-box posture must be secure. AllowInsecureLdap
|
||||
// is a dev-only escape hatch — a deployment that enables LDAP without explicitly
|
||||
// opting in must not bind credentials over an unencrypted socket.
|
||||
|
||||
[Fact]
|
||||
public void AllowInsecureLdap_DefaultsToFalse()
|
||||
{
|
||||
new LdapOptions().AllowInsecureLdap.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseTls_DefaultsToFalse_SoInsecureBindRequiresExplicitOptIn()
|
||||
{
|
||||
// UseTls=false on its own is fine — without AllowInsecureLdap the bind path
|
||||
// refuses to send plaintext credentials. The two flags together are the only
|
||||
// way to reach the insecure path, and both must be set deliberately.
|
||||
var options = new LdapOptions();
|
||||
|
||||
options.UseTls.ShouldBeFalse();
|
||||
options.AllowInsecureLdap.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic guards for Active Directory compatibility of the internal helpers
|
||||
/// <see cref="LdapUserAuthenticator"/> relies on. We can't live-bind against AD in unit
|
||||
/// tests — instead, we pin the behaviors AD depends on (DN-parsing of AD-style
|
||||
/// <c>memberOf</c> values, filter escaping with case-preserving RDN extraction) so a
|
||||
/// future refactor can't silently break the AD path while the GLAuth live-smoke stays
|
||||
/// green.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class LdapUserAuthenticatorAdCompatTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_parses_AD_memberOf_group_name_from_CN_dn()
|
||||
{
|
||||
// AD's memberOf values use uppercase CN=… and full domain paths. The extractor
|
||||
// returns the first RDN's value regardless of attribute-type case, so operators'
|
||||
// GroupToRole keys stay readable ("OPCUA-Operators" not "CN=OPCUA-Operators,...").
|
||||
var dn = "CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("OPCUA-Operators");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_handles_mixed_case_and_spaces_in_group_name()
|
||||
{
|
||||
var dn = "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("Domain Users");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_also_works_for_OpenLDAP_ou_style_memberOf()
|
||||
{
|
||||
// GLAuth + some OpenLDAP deployments expose memberOf as ou=<group>,ou=groups,...
|
||||
// The authenticator needs one extractor that tolerates both shapes since directories
|
||||
// in the field mix them depending on schema.
|
||||
var dn = "ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local";
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe("WriteOperate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EscapeLdapFilter_prevents_injection_via_samaccountname_lookup()
|
||||
{
|
||||
// AD login names can contain characters that are meaningful to LDAP filter syntax
|
||||
// (parens, backslashes). The authenticator builds filters as
|
||||
// ($"({UserNameAttribute}={EscapeLdapFilter(username)})") so injection attempts must
|
||||
// not break out of the filter. The RFC 4515 escape set is: \ → \5c, * → \2a, ( → \28,
|
||||
// ) → \29, \0 → \00.
|
||||
LdapUserAuthenticator.EscapeLdapFilter("admin)(cn=*")
|
||||
.ShouldBe("admin\\29\\28cn=\\2a");
|
||||
LdapUserAuthenticator.EscapeLdapFilter("domain\\user")
|
||||
.ShouldBe("domain\\5cuser");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LdapOptions_default_UserNameAttribute_is_uid_for_rfc2307_compat()
|
||||
{
|
||||
// Regression guard: PR 31 introduced UserNameAttribute with a default of "uid" so
|
||||
// existing deployments (pre-AD config) keep working. Changing the default breaks
|
||||
// everyone's config silently; require an explicit review.
|
||||
new LdapOptions().UserNameAttribute.ShouldBe("uid");
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live-service tests against the dev GLAuth instance at <c>localhost:3893</c>. Skipped
|
||||
/// when the port is unreachable so the test suite stays portable on boxes without a
|
||||
/// running directory. Closes LMX follow-up #4 — the server-side <see cref="LdapUserAuthenticator"/>
|
||||
/// is exercised end-to-end against a real LDAP server (same one the Admin process uses),
|
||||
/// not just the flow-shape unit tests from PR 19.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>Admin.Tests</c> project already has a live-bind test for its own
|
||||
/// <c>LdapAuthService</c>; this pair catches divergence between the two bind paths — the
|
||||
/// Server authenticator has to work even when the Server process is on a machine that
|
||||
/// doesn't have the Admin assemblies loaded, and the two share no code by design
|
||||
/// (cross-app dependency avoidance). If one side drifts past the other on LDAP filter
|
||||
/// construction, DN resolution, or memberOf parsing, these tests surface it.
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class LdapUserAuthenticatorLiveTests
|
||||
{
|
||||
private const string GlauthHost = "localhost";
|
||||
private const int GlauthPort = 3893;
|
||||
|
||||
private static bool GlauthReachable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(GlauthHost, GlauthPort);
|
||||
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// GLAuth dev directory groups are named identically to the OPC UA roles
|
||||
// (ReadOnly / WriteOperate / WriteTune / WriteConfigure / AlarmAck), so the map is an
|
||||
// identity translation. The authenticator still exercises every step of the pipeline —
|
||||
// bind, memberOf lookup, group-name extraction, GroupToRole lookup — against real LDAP
|
||||
// data; the identity map just means the assertion is phrased with no surprise rename
|
||||
// in the middle.
|
||||
private static LdapOptions GlauthOptions() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = GlauthHost,
|
||||
Port = GlauthPort,
|
||||
UseTls = false,
|
||||
AllowInsecureLdap = true,
|
||||
SearchBase = "dc=lmxopcua,dc=local",
|
||||
// Search-then-bind: service account resolves the user's full DN (cn=<user> lives
|
||||
// under ou=<primary-group>,ou=users), the authenticator binds that DN with the
|
||||
// user's password, then stays on the service-account session for memberOf lookup.
|
||||
// Without this path, GLAuth ACLs block the authenticated user from reading their
|
||||
// own entry in full — a plain self-search returns zero results and the role list
|
||||
// ends up empty.
|
||||
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
ServiceAccountPassword = "serviceaccount123",
|
||||
DisplayNameAttribute = "cn",
|
||||
GroupAttribute = "memberOf",
|
||||
UserNameAttribute = "cn", // GLAuth keys users by cn — see LdapOptions xml-doc.
|
||||
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ReadOnly",
|
||||
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
|
||||
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
|
||||
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
|
||||
["AlarmAck"] = "AlarmAck",
|
||||
},
|
||||
};
|
||||
|
||||
private static LdapUserAuthenticator NewAuthenticator() =>
|
||||
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_credentials_bind_and_return_success()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("readonly", "readonly123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.DisplayName.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writeop_user_gets_WriteOperate_role_from_group_mapping()
|
||||
{
|
||||
// Drives end-to-end: bind as writeop, memberOf lists the WriteOperate group, the
|
||||
// authenticator surfaces WriteOperate via GroupToRole. If this test fails,
|
||||
// WriteAuthzPolicy.IsAllowed for an Operate-tier write would also fail
|
||||
// (WriteOperate is the exact string the policy checks for), so the failure mode is
|
||||
// concrete, not abstract.
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("writeop", "writeop123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_user_gets_multiple_roles_from_multiple_groups()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
// 'admin' has primarygroup=ReadOnly and othergroups=[WriteOperate, AlarmAck,
|
||||
// WriteTune, WriteConfigure] per the GLAuth dev config — the authenticator must
|
||||
// surface every mapped role, not just the primary group. Guards against a regression
|
||||
// where the memberOf parsing stops after the first match or misses the primary-group
|
||||
// fallback.
|
||||
var result = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeTrue(result.Error);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteOperate);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteTune);
|
||||
result.Roles.ShouldContain(WriteAuthzPolicy.RoleWriteConfigure);
|
||||
result.Roles.ShouldContain("AlarmAck");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_password_returns_failure()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-pw", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_user_returns_failure()
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var result = await NewAuthenticator().AuthenticateAsync("no-such-user-42", "whatever", TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_credentials_fail_without_touching_the_directory()
|
||||
{
|
||||
// Pre-flight guard — doesn't require GLAuth.
|
||||
var result = await NewAuthenticator().AuthenticateAsync("", "", TestContext.Current.CancellationToken);
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldContain("Credentials", Case.Insensitive);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DriverNodeManager.GateMonitoredItemCreateRequests"/> —
|
||||
/// Phase 6.2 Stream C per-item subscription gating. Pre-populates the errors array
|
||||
/// with <see cref="StatusCodes.BadUserAccessDenied"/> for denied items; base stack
|
||||
/// honours the pre-set error and skips the item.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MonitoredItemGatingTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gate_null_leaves_errors_untouched()
|
||||
{
|
||||
var items = new List<MonitoredItemCreateRequest> { NewRequest("c1/area/line/eq/tag1") };
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, new UserIdentity(), gate: null, scopeResolver: null);
|
||||
|
||||
errors[0].ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Denied_item_gets_BadUserAccessDenied()
|
||||
{
|
||||
var items = new List<MonitoredItemCreateRequest> { NewRequest("c1/area/line/eq/tag1") };
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: true, rows: []); // no grants → deny
|
||||
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allowed_item_is_not_touched()
|
||||
{
|
||||
var items = new List<MonitoredItemCreateRequest> { NewRequest("c1/area/line/eq/tag1") };
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.Subscribe)]);
|
||||
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
errors[0].ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_batch_denies_per_item()
|
||||
{
|
||||
var items = new List<MonitoredItemCreateRequest>
|
||||
{
|
||||
NewRequest("c1/area/line/eq/tagA"),
|
||||
NewRequest("c1/area/line/eq/tagB"),
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null!, (ServiceResult)null! };
|
||||
// Grant Browse not CreateMonitoredItems → still denied for this op
|
||||
var gate = MakeGate(strict: true, rows: [Row("grp-ops", NodePermissions.Browse)]);
|
||||
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, NewIdentity("alice", "grp-ops"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||
ServiceResult.IsBad(errors[1]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pre_populated_error_is_preserved()
|
||||
{
|
||||
// Base stack may have already flagged an item (e.g. BadNodeIdUnknown). The gate
|
||||
// must not overwrite that with a generic BadUserAccessDenied — the first diagnosis
|
||||
// wins.
|
||||
var items = new List<MonitoredItemCreateRequest> { NewRequest("c1/area/line/eq/tag1") };
|
||||
var errors = new List<ServiceResult> { new(StatusCodes.BadNodeIdUnknown) };
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_string_identifier_bypasses_the_gate()
|
||||
{
|
||||
// Numeric-id references (standard-type nodes) aren't keyed into the authz trie.
|
||||
var items = new List<MonitoredItemCreateRequest>
|
||||
{
|
||||
new() { ItemToMonitor = new ReadValueId { NodeId = new NodeId(62u) } },
|
||||
};
|
||||
var errors = new List<ServiceResult> { (ServiceResult)null! };
|
||||
var gate = MakeGate(strict: true, rows: []);
|
||||
|
||||
DriverNodeManager.GateMonitoredItemCreateRequests(items, errors, NewIdentity("alice"), gate, new NodeScopeResolver("c1"));
|
||||
|
||||
errors[0].ShouldBeNull("numeric-id references bypass the gate");
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
private static MonitoredItemCreateRequest NewRequest(string fullRef) => new()
|
||||
{
|
||||
ItemToMonitor = new ReadValueId { NodeId = new NodeId(fullRef, 2) },
|
||||
};
|
||||
|
||||
private static NodeAcl Row(string group, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = "c1",
|
||||
LdapGroup = group,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
ScopeId = null,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows)
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build("c1", 1, rows));
|
||||
var evaluator = new TriePermissionEvaluator(cache);
|
||||
return new AuthorizationGate(evaluator, strictMode: strict, trieCache: cache);
|
||||
}
|
||||
|
||||
private static IUserIdentity NewIdentity(string name, params string[] groups) => new FakeIdentity(name, groups);
|
||||
|
||||
private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public FakeIdentity(string name, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = name;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Closes LMX follow-up #6 — proves that two <see cref="IDriver"/> instances registered
|
||||
/// on the same <see cref="DriverHost"/> land in isolated namespaces and their reads
|
||||
/// route to the correct driver. The existing <see cref="OpcUaServerIntegrationTests"/>
|
||||
/// only exercises a single-driver topology; this sibling fixture registers two.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each driver gets its own namespace URI of the form <c>urn:OtOpcUa:{DriverInstanceId}</c>
|
||||
/// (per <c>DriverNodeManager</c>'s base-class <c>namespaceUris</c> argument). A client
|
||||
/// that browses one namespace must see only that driver's subtree, and a read against a
|
||||
/// variable in one namespace must return that driver's value, not the other's — this is
|
||||
/// what stops a cross-driver routing regression from going unnoticed when the v1
|
||||
/// single-driver code path gets new knobs.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class MultipleDriverInstancesIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaMultiDriverTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multi-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
await _driverHost.RegisterAsync(new StubDriver("alpha", folderName: "AlphaFolder", readValue: 42),
|
||||
"{}", CancellationToken.None);
|
||||
await _driverHost.RegisterAsync(new StubDriver("beta", folderName: "BetaFolder", readValue: 99),
|
||||
"{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaMultiDriverTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:MultiDriverTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true, HealthEndpointsEnabled = false,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Both_drivers_register_under_their_own_urn_namespace()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
alphaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'alpha' must register its namespace URI");
|
||||
betaNs.ShouldBeGreaterThanOrEqualTo(0, "DriverNodeManager for 'beta' must register its namespace URI");
|
||||
alphaNs.ShouldNotBe(betaNs, "each driver owns its own namespace");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_driver_subtree_exposes_only_its_own_folder()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
var alphaRoot = new NodeId("alpha", alphaNs);
|
||||
session.Browse(null, null, alphaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var alphaRefs);
|
||||
alphaRefs.ShouldContain(r => r.BrowseName.Name == "AlphaFolder",
|
||||
"alpha's subtree must contain alpha's folder");
|
||||
alphaRefs.ShouldNotContain(r => r.BrowseName.Name == "BetaFolder",
|
||||
"alpha's subtree must NOT see beta's folder — cross-driver leak would hide subscription-routing bugs");
|
||||
|
||||
var betaRoot = new NodeId("beta", betaNs);
|
||||
session.Browse(null, null, betaRoot, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var betaRefs);
|
||||
betaRefs.ShouldContain(r => r.BrowseName.Name == "BetaFolder");
|
||||
betaRefs.ShouldNotContain(r => r.BrowseName.Name == "AlphaFolder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_route_to_the_correct_driver_by_namespace()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var alphaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:alpha");
|
||||
var betaNs = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:beta");
|
||||
|
||||
// Path-based NodeId per #134 — `{driverId}/{folder}/{browseName}`.
|
||||
var alphaValue = session.ReadValue(new NodeId("alpha/AlphaFolder/Var1", alphaNs));
|
||||
var betaValue = session.ReadValue(new NodeId("beta/BetaFolder/Var1", betaNs));
|
||||
|
||||
alphaValue.Value.ShouldBe(42, "alpha driver's ReadAsync returns 42 — a misroute would surface as 99");
|
||||
betaValue.Value.ShouldBe(99, "beta driver's ReadAsync returns 99 — a misroute would surface as 42");
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaMultiDriverTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:MultiDriverTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaMultiDriverTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaMultiDriverTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver stub that returns a caller-specified folder + variable + read value so two
|
||||
/// instances in the same server can be told apart at the assertion layer.
|
||||
/// </summary>
|
||||
private sealed class StubDriver(string driverInstanceId, string folderName, int readValue)
|
||||
: IDriver, ITagDiscovery, IReadable
|
||||
{
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "Stub";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var folder = builder.Folder(folderName, folderName);
|
||||
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
|
||||
$"{folderName}.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> result =
|
||||
fullReferences.Select(_ => new DataValueSnapshot(readValue, 0u, now, now)).ToArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NodeBootstrapTests
|
||||
{
|
||||
private sealed class StubCache : ILocalConfigCache
|
||||
{
|
||||
public GenerationSnapshot? Stored { get; set; }
|
||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string _, CancellationToken __) => Task.FromResult(Stored);
|
||||
public Task PutAsync(GenerationSnapshot _, CancellationToken __) => Task.CompletedTask;
|
||||
public Task PruneOldGenerationsAsync(string _, int __, CancellationToken ___) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Falls_back_to_cache_when_DB_unreachable()
|
||||
{
|
||||
var cache = new StubCache
|
||||
{
|
||||
Stored = new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "c", GenerationId = 42, CachedAt = DateTime.UtcNow, PayloadJson = "{}",
|
||||
},
|
||||
};
|
||||
|
||||
var bootstrap = new NodeBootstrap(
|
||||
new NodeOptions
|
||||
{
|
||||
NodeId = "n",
|
||||
ClusterId = "c",
|
||||
ConfigDbConnectionString = "Server=127.0.0.1,59999;Database=nope;User Id=x;Password=x;TrustServerCertificate=True;Connect Timeout=1;",
|
||||
},
|
||||
cache,
|
||||
NullLogger<NodeBootstrap>.Instance);
|
||||
|
||||
var result = await bootstrap.LoadCurrentGenerationAsync(CancellationToken.None);
|
||||
|
||||
result.Source.ShouldBe(BootstrapSource.LocalCache);
|
||||
result.GenerationId.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_BootstrapException_when_DB_unreachable_and_cache_empty()
|
||||
{
|
||||
var bootstrap = new NodeBootstrap(
|
||||
new NodeOptions
|
||||
{
|
||||
NodeId = "n",
|
||||
ClusterId = "c",
|
||||
ConfigDbConnectionString = "Server=127.0.0.1,59999;Database=nope;User Id=x;Password=x;TrustServerCertificate=True;Connect Timeout=1;",
|
||||
},
|
||||
new StubCache(),
|
||||
NullLogger<NodeBootstrap>.Instance);
|
||||
|
||||
await Should.ThrowAsync<BootstrapException>(() =>
|
||||
bootstrap.LoadCurrentGenerationAsync(CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class NodeScopeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resolve_PopulatesClusterAndTag()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-warsaw");
|
||||
|
||||
var scope = resolver.Resolve("TestMachine_001/Oven/SetPoint");
|
||||
|
||||
scope.ClusterId.ShouldBe("c-warsaw");
|
||||
scope.TagId.ShouldBe("TestMachine_001/Oven/SetPoint");
|
||||
scope.Kind.ShouldBe(NodeHierarchyKind.Equipment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Leaves_UnsPath_Null_When_NoIndexSupplied()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-1");
|
||||
|
||||
var scope = resolver.Resolve("tag-1");
|
||||
|
||||
// Cluster-only fallback path — used pre-ADR-001 and still the active path for
|
||||
// unindexed references (e.g. driver-discovered tags that have no Tag row yet).
|
||||
scope.NamespaceId.ShouldBeNull();
|
||||
scope.UnsAreaId.ShouldBeNull();
|
||||
scope.UnsLineId.ShouldBeNull();
|
||||
scope.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Returns_IndexedScope_When_FullReferenceFound()
|
||||
{
|
||||
var index = new Dictionary<string, NodeScope>
|
||||
{
|
||||
["plcaddr-01"] = new NodeScope
|
||||
{
|
||||
ClusterId = "c-1", NamespaceId = "ns-plc", UnsAreaId = "area-1",
|
||||
UnsLineId = "line-a", EquipmentId = "eq-oven-3", TagId = "plcaddr-01",
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
},
|
||||
};
|
||||
var resolver = new NodeScopeResolver("c-1", index);
|
||||
|
||||
var scope = resolver.Resolve("plcaddr-01");
|
||||
|
||||
scope.UnsAreaId.ShouldBe("area-1");
|
||||
scope.UnsLineId.ShouldBe("line-a");
|
||||
scope.EquipmentId.ShouldBe("eq-oven-3");
|
||||
scope.TagId.ShouldBe("plcaddr-01");
|
||||
scope.NamespaceId.ShouldBe("ns-plc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FallsBack_To_ClusterOnly_When_Reference_NotIndexed()
|
||||
{
|
||||
var index = new Dictionary<string, NodeScope>
|
||||
{
|
||||
["plcaddr-01"] = new NodeScope { ClusterId = "c-1", TagId = "plcaddr-01", Kind = NodeHierarchyKind.Equipment },
|
||||
};
|
||||
var resolver = new NodeScopeResolver("c-1", index);
|
||||
|
||||
var scope = resolver.Resolve("not-in-index");
|
||||
|
||||
scope.ClusterId.ShouldBe("c-1");
|
||||
scope.TagId.ShouldBe("not-in-index");
|
||||
scope.EquipmentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Throws_OnEmptyFullReference()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c-1");
|
||||
|
||||
Should.Throw<ArgumentException>(() => resolver.Resolve(""));
|
||||
Should.Throw<ArgumentException>(() => resolver.Resolve(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_Throws_OnEmptyClusterId()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() => new NodeScopeResolver(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolver_IsStateless_AcrossCalls()
|
||||
{
|
||||
var resolver = new NodeScopeResolver("c");
|
||||
var s1 = resolver.Resolve("tag-a");
|
||||
var s2 = resolver.Resolve("tag-b");
|
||||
|
||||
s1.TagId.ShouldBe("tag-a");
|
||||
s2.TagId.ShouldBe("tag-b");
|
||||
s1.ClusterId.ShouldBe("c");
|
||||
s2.ClusterId.ShouldBe("c");
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when
|
||||
/// <see cref="OpcUaApplicationHost"/> is given an <c>equipmentContentLookup</c> that
|
||||
/// returns a non-null <see cref="EquipmentNamespaceContent"/>, the walker runs BEFORE
|
||||
/// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) +
|
||||
/// identifier properties are materialized into the driver's namespace + visible to an
|
||||
/// OPC UA client via standard browse.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private static readonly int Port = 48500 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}");
|
||||
private const string DriverId = "galaxy-prod";
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None);
|
||||
|
||||
var content = BuildFixture();
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaWalkerTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:WalkerTest",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
HealthEndpointsEnabled = false,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(
|
||||
options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance,
|
||||
equipmentContentLookup: id => id == DriverId ? content : null);
|
||||
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
||||
|
||||
var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex);
|
||||
var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex);
|
||||
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
||||
|
||||
BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a");
|
||||
BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3");
|
||||
|
||||
var equipmentChildren = BrowseChildren(session, equipmentFolder);
|
||||
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId");
|
||||
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid");
|
||||
equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}");
|
||||
|
||||
// Path-based NodeId per #134 — `{driverId}/{areaName}/{lineName}/{equipmentName}/{tagName}`.
|
||||
// The walker uses Tag.Name as the browseName, so the FullReference (TagConfig content
|
||||
// "plcaddr-temperature") does not appear in the NodeId path.
|
||||
var tagNode = new NodeId($"{DriverId}/warsaw/line-a/oven-3/Temperature", nsIndex);
|
||||
var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex);
|
||||
|
||||
BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature");
|
||||
|
||||
var dv = session.ReadValue(tagNode);
|
||||
dv.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node)
|
||||
{
|
||||
session.Browse(null, null, node, 0, BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences, true,
|
||||
(uint)NodeClass.Object | (uint)NodeClass.Variable,
|
||||
out _, out var refs);
|
||||
return refs;
|
||||
}
|
||||
|
||||
private static EquipmentNamespaceContent BuildFixture()
|
||||
{
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 };
|
||||
var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 };
|
||||
var oven = new Equipment
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(),
|
||||
DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3",
|
||||
MachineCode = "MC-oven-3",
|
||||
};
|
||||
var tempTag = new Tag
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1",
|
||||
DriverInstanceId = DriverId, EquipmentId = "eq-oven-3",
|
||||
Name = "Temperature", DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature",
|
||||
};
|
||||
|
||||
return new EquipmentNamespaceContent(
|
||||
Areas: new[] { area },
|
||||
Lines: new[] { line },
|
||||
Equipment: new[] { oven },
|
||||
Tags: new[] { tempTag });
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaWalkerTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:WalkerTestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaWalkerTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Driver that registers into the host + implements DiscoverAsync as a no-op. The
|
||||
/// walker is the sole source of address-space content; if the UNS folders appear
|
||||
/// under browse, they came from the wire-in (not from the driver's own discovery).
|
||||
/// </summary>
|
||||
private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable
|
||||
{
|
||||
public EmptyDriver(string id) { DriverInstanceId = id; }
|
||||
public string DriverInstanceId { get; }
|
||||
public string DriverType => "EmptyForWalkerTest";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> result =
|
||||
fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpcUaServerIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
// Use a non-default port + per-test-run PKI root to avoid colliding with anything else
|
||||
// running on the box (a live v1 Host or a developer's previous run).
|
||||
private static readonly int Port = 48400 + Random.Shared.Next(0, 99);
|
||||
private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaTest";
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-test-{Guid.NewGuid():N}");
|
||||
|
||||
private DriverHost _driverHost = null!;
|
||||
private OpcUaApplicationHost _server = null!;
|
||||
private FakeDriver _driver = null!;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_driverHost = new DriverHost();
|
||||
_driver = new FakeDriver();
|
||||
await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None);
|
||||
|
||||
var options = new OpcUaServerOptions
|
||||
{
|
||||
EndpointUrl = _endpoint,
|
||||
ApplicationName = "OtOpcUaTest",
|
||||
ApplicationUri = "urn:OtOpcUa:Server:Test",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
AutoAcceptUntrustedClientCertificates = true, HealthEndpointsEnabled = false,
|
||||
};
|
||||
|
||||
_server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(),
|
||||
NullLoggerFactory.Instance, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await _server.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _driverHost.DisposeAsync();
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_can_connect_and_browse_driver_subtree()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
// Browse the driver subtree registered under ObjectsFolder. FakeDriver registers one
|
||||
// folder ("TestFolder") with one variable ("Var1"), so we expect to see our driver's
|
||||
// root folder plus standard UA children.
|
||||
var rootRef = new NodeId("fake", (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake"));
|
||||
session.Browse(null, null, rootRef, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences,
|
||||
true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var references);
|
||||
|
||||
references.Count.ShouldBeGreaterThan(0);
|
||||
references.ShouldContain(r => r.BrowseName.Name == "TestFolder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_can_read_a_driver_variable_through_the_node_manager()
|
||||
{
|
||||
using var session = await OpenSessionAsync();
|
||||
|
||||
var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake");
|
||||
// Path-based NodeId per #134 — `{driverId}/{folder-path}/{browseName}`. The driver-side
|
||||
// FullReference ("TestFolder.Var1") is now decoupled from the NodeId so a backend rename
|
||||
// doesn't shift the identifier seen by clients (OPC UA Part 3 §5.2.2 immutability).
|
||||
var varNodeId = new NodeId("fake/TestFolder/Var1", nsIndex);
|
||||
|
||||
var dv = session.ReadValue(varNodeId);
|
||||
dv.ShouldNotBeNull();
|
||||
// FakeDriver.ReadAsync returns 42 as the value.
|
||||
dv.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
private async Task<ISession> OpenSessionAsync()
|
||||
{
|
||||
var cfg = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUaTestClient",
|
||||
ApplicationUri = "urn:OtOpcUa:TestClient",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
{
|
||||
StoreType = CertificateStoreType.Directory,
|
||||
StorePath = Path.Combine(_pkiRoot, "client-own"),
|
||||
SubjectName = "CN=OtOpcUaTestClient",
|
||||
},
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") },
|
||||
AutoAcceptUntrustedCertificates = true,
|
||||
AddAppCertToTrustedStore = true,
|
||||
},
|
||||
TransportConfigurations = new TransportConfigurationCollection(),
|
||||
TransportQuotas = new TransportQuotas { OperationTimeout = 15000 },
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 },
|
||||
};
|
||||
await cfg.Validate(ApplicationType.Client);
|
||||
cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client };
|
||||
await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize);
|
||||
|
||||
// Let the client fetch the live endpoint description from the running server so the
|
||||
// UserTokenPolicy it signs with matches what the server actually advertised (including
|
||||
// the PolicyId = "Anonymous" the server sets).
|
||||
var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false);
|
||||
var endpointConfig = EndpointConfiguration.Create(cfg);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||
|
||||
return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaTestClientSession", 60000,
|
||||
new UserIdentity(new AnonymousIdentityToken()), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum driver that implements enough of IDriver + ITagDiscovery + IReadable to drive
|
||||
/// the integration test. Returns a single folder with one variable that reads as 42.
|
||||
/// </summary>
|
||||
private sealed class FakeDriver : IDriver, ITagDiscovery, IReadable
|
||||
{
|
||||
public string DriverInstanceId => "fake";
|
||||
public string DriverType => "Fake";
|
||||
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
var folder = builder.Folder("TestFolder", "TestFolder");
|
||||
folder.Variable("Var1", "Var1", new DriverAttributeInfo(
|
||||
"TestFolder.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IReadOnlyList<DataValueSnapshot> result =
|
||||
fullReferences.Select(_ => new DataValueSnapshot(42, 0u, now, now)).ToArray();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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.Server.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PeerHttpProbeLoop"/>. Drives <c>TickAsync</c> synchronously
|
||||
/// via a <see cref="IHttpClientFactory"/> test double so we don't race the loop's
|
||||
/// <c>Task.Delay</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PeerHttpProbeLoopTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
|
||||
public PeerHttpProbeLoopTests()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"peer-http-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(opts);
|
||||
_dbFactory = new DbContextFactory(opts);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_with_no_peers_is_a_no_op()
|
||||
{
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
var coordinator = await SeedAndInitializeAsync("A", ("A", RedundancyRole.Primary, "urn:A"));
|
||||
|
||||
var loop = new PeerHttpProbeLoop(coordinator, tracker,
|
||||
new StubHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.OK)),
|
||||
NullLogger<PeerHttpProbeLoop>.Instance);
|
||||
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
tracker.Get("B").ShouldBe(PeerReachability.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_marks_peer_healthy_when_healthz_returns_200()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
var factory = new StubHttpClientFactory(req =>
|
||||
{
|
||||
req.RequestUri!.AbsolutePath.ShouldBe("/healthz");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
});
|
||||
|
||||
var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger<PeerHttpProbeLoop>.Instance);
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
tracker.Get("B").HttpHealthy.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_marks_peer_unhealthy_when_healthz_throws()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
var factory = new StubHttpClientFactory(_ => throw new HttpRequestException("no route to host"));
|
||||
|
||||
var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger<PeerHttpProbeLoop>.Instance);
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
tracker.Get("B").HttpHealthy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_preserves_UaHealthy_bit_when_flipping_HttpHealthy()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
tracker.Update("B", new PeerReachability(HttpHealthy: false, UaHealthy: true));
|
||||
|
||||
var factory = new StubHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger<PeerHttpProbeLoop>.Instance);
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
var current = tracker.Get("B");
|
||||
current.HttpHealthy.ShouldBeTrue();
|
||||
current.UaHealthy.ShouldBeTrue("UA bit must not be clobbered by the HTTP probe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_marks_peer_unhealthy_on_non_2xx_response()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
var factory = new StubHttpClientFactory(_ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
|
||||
|
||||
var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger<PeerHttpProbeLoop>.Instance);
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
tracker.Get("B").HttpHealthy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_does_not_mutate_factory_vended_client_Timeout()
|
||||
{
|
||||
// Server-012: timeouts belong on the named-client registration or a per-request CTS,
|
||||
// NOT on a factory-vended HttpClient (which IHttpClientFactory may pool/recycle).
|
||||
// Mutating client.Timeout per tick is at minimum a bad smell and races with
|
||||
// IHttpClientFactory's lifecycle expectations.
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
var factoryInitialTimeout = TimeSpan.FromMinutes(2);
|
||||
var factory = new RecordingHttpClientFactory(
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK),
|
||||
factoryInitialTimeout);
|
||||
|
||||
var loop = new PeerHttpProbeLoop(coordinator, tracker, factory, NullLogger<PeerHttpProbeLoop>.Instance,
|
||||
options: new PeerProbeOptions { HttpProbeTimeout = TimeSpan.FromSeconds(3) });
|
||||
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
factory.LastCreatedClient.ShouldNotBeNull();
|
||||
factory.LastCreatedClient.Timeout.ShouldBe(factoryInitialTimeout,
|
||||
"the probe loop must not mutate the factory-vended HttpClient's Timeout — "
|
||||
+ "per-call timeout should be enforced via a CancellationToken or via "
|
||||
+ "AddHttpClient.ConfigureHttpClient on the named registration.");
|
||||
}
|
||||
|
||||
// ---- fixture helpers ---------------------------------------------------
|
||||
|
||||
private async Task<RedundancyCoordinator> SeedAndInitializeAsync(string selfNodeId, params (string id, RedundancyRole role, string appUri)[] nodes)
|
||||
{
|
||||
_db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "c1", Name = "Warsaw", Enterprise = "zb", Site = "warsaw",
|
||||
RedundancyMode = nodes.Length == 1 ? RedundancyMode.None : RedundancyMode.Warm,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
foreach (var (id, role, appUri) in nodes)
|
||||
{
|
||||
_db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = id, ClusterId = "c1",
|
||||
RedundancyRole = role, Host = id.ToLowerInvariant(),
|
||||
ApplicationUri = appUri, CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, selfNodeId, "c1");
|
||||
await coordinator.InitializeAsync(CancellationToken.None);
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||
: IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => new(options);
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory(Func<HttpRequestMessage, HttpResponseMessage> respond) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) =>
|
||||
new(new StubHandler(respond), disposeHandler: true) { Timeout = TimeSpan.FromSeconds(1) };
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-012 — captures the most-recently-vended <see cref="HttpClient"/> so the
|
||||
/// test can assert the probe loop didn't mutate its <see cref="HttpClient.Timeout"/>.
|
||||
/// </summary>
|
||||
private sealed class RecordingHttpClientFactory(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> respond,
|
||||
TimeSpan initialTimeout) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient? LastCreatedClient { get; private set; }
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
var client = new HttpClient(new RecordingHandler(respond), disposeHandler: true)
|
||||
{
|
||||
Timeout = initialTimeout,
|
||||
};
|
||||
LastCreatedClient = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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.Server.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PeerUaProbeLoop"/>. Drives <c>TickAsync</c> synchronously
|
||||
/// with an injected endpoint-probe delegate so no real OPC UA server is needed.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PeerUaProbeLoopTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
|
||||
public PeerUaProbeLoopTests()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"peer-ua-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(opts);
|
||||
_dbFactory = new DbContextFactory(opts);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_short_circuits_when_HttpHealthy_is_false()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
tracker.Update("B", new PeerReachability(HttpHealthy: false, UaHealthy: true));
|
||||
|
||||
var probeCallCount = 0;
|
||||
var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger<PeerUaProbeLoop>.Instance,
|
||||
options: null,
|
||||
endpointProbe: (_, _, _) => { probeCallCount++; return Task.FromResult(true); });
|
||||
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
probeCallCount.ShouldBe(0, "UA probe must not run when HTTP reports the peer unhealthy");
|
||||
var current = tracker.Get("B");
|
||||
current.HttpHealthy.ShouldBeFalse();
|
||||
current.UaHealthy.ShouldBeFalse("stale UaHealthy=true must be cleared when HTTP says dead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_marks_UaHealthy_true_when_probe_succeeds()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
tracker.Update("B", new PeerReachability(HttpHealthy: true, UaHealthy: false));
|
||||
|
||||
string? calledEndpoint = null;
|
||||
var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger<PeerUaProbeLoop>.Instance,
|
||||
options: null,
|
||||
endpointProbe: (endpoint, _, _) => { calledEndpoint = endpoint; return Task.FromResult(true); });
|
||||
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
calledEndpoint.ShouldNotBeNull();
|
||||
calledEndpoint!.ShouldStartWith("opc.tcp://b:");
|
||||
tracker.Get("B").UaHealthy.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_marks_UaHealthy_false_when_probe_fails()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
tracker.Update("B", new PeerReachability(HttpHealthy: true, UaHealthy: true));
|
||||
|
||||
var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger<PeerUaProbeLoop>.Instance,
|
||||
options: null,
|
||||
endpointProbe: (_, _, _) => Task.FromResult(false));
|
||||
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
tracker.Get("B").UaHealthy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_preserves_HttpHealthy_bit_across_UA_update()
|
||||
{
|
||||
var coordinator = await SeedAndInitializeAsync("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var tracker = new PeerReachabilityTracker();
|
||||
tracker.Update("B", new PeerReachability(HttpHealthy: true, UaHealthy: false));
|
||||
|
||||
var loop = new PeerUaProbeLoop(coordinator, tracker, NullLogger<PeerUaProbeLoop>.Instance,
|
||||
options: null,
|
||||
endpointProbe: (_, _, _) => Task.FromResult(true));
|
||||
|
||||
await loop.TickAsync(CancellationToken.None);
|
||||
|
||||
var current = tracker.Get("B");
|
||||
current.HttpHealthy.ShouldBeTrue("HTTP bit must not be clobbered by the UA probe");
|
||||
current.UaHealthy.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- fixture helpers ---------------------------------------------------
|
||||
|
||||
private async Task<RedundancyCoordinator> SeedAndInitializeAsync(string selfNodeId, params (string id, RedundancyRole role, string appUri)[] nodes)
|
||||
{
|
||||
_db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "c1", Name = "Warsaw", Enterprise = "zb", Site = "warsaw",
|
||||
RedundancyMode = nodes.Length == 1 ? RedundancyMode.None : RedundancyMode.Warm,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
foreach (var (id, role, appUri) in nodes)
|
||||
{
|
||||
_db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = id, ClusterId = "c1",
|
||||
RedundancyRole = role, Host = id.ToLowerInvariant(),
|
||||
ApplicationUri = appUri, CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, selfNodeId, "c1");
|
||||
await coordinator.InitializeAsync(CancellationToken.None);
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||
: IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => new(options);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the Phase 7 driver-to-engine bridge cache (task #243). Verifies the
|
||||
/// cache serves last-known values synchronously, fans out Push updates to
|
||||
/// subscribers, and cleans up on Dispose.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CachedTagUpstreamSourceTests
|
||||
{
|
||||
private static DataValueSnapshot Snap(object? v) =>
|
||||
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void ReadTag_unknown_path_returns_BadNodeIdUnknown_snapshot()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var snap = c.ReadTag("/nowhere");
|
||||
snap.Value.ShouldBeNull();
|
||||
snap.StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_then_Read_returns_cached_value()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
c.Push("/Line1/Temp", Snap(42));
|
||||
c.ReadTag("/Line1/Temp").Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_fans_out_to_subscribers_in_registration_order()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var events = new List<string>();
|
||||
c.SubscribeTag("/X", (p, s) => events.Add($"A:{p}:{s.Value}"));
|
||||
c.SubscribeTag("/X", (p, s) => events.Add($"B:{p}:{s.Value}"));
|
||||
|
||||
c.Push("/X", Snap(7));
|
||||
|
||||
events.ShouldBe(["A:/X:7", "B:/X:7"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Push_to_different_path_does_not_fire_foreign_observer()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var fired = 0;
|
||||
c.SubscribeTag("/X", (_, _) => fired++);
|
||||
|
||||
c.Push("/Y", Snap(1));
|
||||
fired.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_of_subscription_stops_fan_out()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
var fired = 0;
|
||||
var sub = c.SubscribeTag("/X", (_, _) => fired++);
|
||||
|
||||
c.Push("/X", Snap(1));
|
||||
sub.Dispose();
|
||||
c.Push("/X", Snap(2));
|
||||
|
||||
fired.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Satisfies_both_VirtualTag_and_ScriptedAlarm_upstream_interfaces()
|
||||
{
|
||||
var c = new CachedTagUpstreamSource();
|
||||
// Single instance is assignable to both — the composer passes it through for
|
||||
// both engine constructors per the task #243 wiring.
|
||||
((Core.VirtualTags.ITagUpstreamSource)c).ShouldNotBeNull();
|
||||
((Core.ScriptedAlarms.ITagUpstreamSource)c).ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Task #244 — covers the bridge that pumps live driver <c>OnDataChange</c>
|
||||
/// notifications into the Phase 7 <see cref="CachedTagUpstreamSource"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DriverSubscriptionBridgeTests
|
||||
{
|
||||
private sealed class FakeDriver : ISubscribable
|
||||
{
|
||||
public List<IReadOnlyList<string>> SubscribeCalls { get; } = [];
|
||||
public List<ISubscriptionHandle> Unsubscribed { get; } = [];
|
||||
public ISubscriptionHandle? LastHandle { get; private set; }
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
SubscribeCalls.Add(fullReferences);
|
||||
LastHandle = new Handle($"sub-{SubscribeCalls.Count}");
|
||||
return Task.FromResult(LastHandle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
Unsubscribed.Add(handle);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Fire(string fullRef, object value)
|
||||
{
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(
|
||||
LastHandle!, fullRef,
|
||||
new DataValueSnapshot(value, 0u, DateTime.UtcNow, DateTime.UtcNow)));
|
||||
}
|
||||
|
||||
private sealed record Handle(string DiagnosticId) : ISubscriptionHandle;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_calls_SubscribeAsync_with_distinct_fullRefs()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var driver = new FakeDriver();
|
||||
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
|
||||
await bridge.StartAsync(new[]
|
||||
{
|
||||
new DriverFeed(driver,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["/Site/L1/A/Temp"] = "DR.Temp",
|
||||
["/Site/L1/A/Pressure"] = "DR.Pressure",
|
||||
},
|
||||
TimeSpan.FromSeconds(1)),
|
||||
}, CancellationToken.None);
|
||||
|
||||
driver.SubscribeCalls.Count.ShouldBe(1);
|
||||
driver.SubscribeCalls[0].ShouldContain("DR.Temp");
|
||||
driver.SubscribeCalls[0].ShouldContain("DR.Pressure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnDataChange_pushes_to_cache_keyed_by_UNS_path()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var driver = new FakeDriver();
|
||||
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
|
||||
await bridge.StartAsync(new[]
|
||||
{
|
||||
new DriverFeed(driver,
|
||||
new Dictionary<string, string> { ["/Site/L1/A/Temp"] = "DR.Temp" },
|
||||
TimeSpan.FromSeconds(1)),
|
||||
}, CancellationToken.None);
|
||||
|
||||
driver.Fire("DR.Temp", 42.5);
|
||||
|
||||
sink.ReadTag("/Site/L1/A/Temp").Value.ShouldBe(42.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnDataChange_with_unmapped_fullRef_is_ignored()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var driver = new FakeDriver();
|
||||
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
|
||||
await bridge.StartAsync(new[]
|
||||
{
|
||||
new DriverFeed(driver,
|
||||
new Dictionary<string, string> { ["/p"] = "DR.A" },
|
||||
TimeSpan.FromSeconds(1)),
|
||||
}, CancellationToken.None);
|
||||
|
||||
driver.Fire("DR.B", 99); // not in map
|
||||
|
||||
sink.ReadTag("/p").StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured,
|
||||
"unmapped fullRef shouldn't pollute the cache");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_PathToFullRef_skips_SubscribeAsync_call()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var driver = new FakeDriver();
|
||||
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
|
||||
await bridge.StartAsync(new[]
|
||||
{
|
||||
new DriverFeed(driver, new Dictionary<string, string>(), TimeSpan.FromSeconds(1)),
|
||||
}, CancellationToken.None);
|
||||
|
||||
driver.SubscribeCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_unsubscribes_each_active_subscription()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var driver = new FakeDriver();
|
||||
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
|
||||
await bridge.StartAsync(new[]
|
||||
{
|
||||
new DriverFeed(driver,
|
||||
new Dictionary<string, string> { ["/p"] = "DR.A" },
|
||||
TimeSpan.FromSeconds(1)),
|
||||
}, CancellationToken.None);
|
||||
|
||||
await bridge.DisposeAsync();
|
||||
|
||||
driver.Unsubscribed.Count.ShouldBe(1);
|
||||
driver.Unsubscribed[0].ShouldBeSameAs(driver.LastHandle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_unhooks_OnDataChange_so_post_dispose_events_dont_push()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var driver = new FakeDriver();
|
||||
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
|
||||
await bridge.StartAsync(new[]
|
||||
{
|
||||
new DriverFeed(driver,
|
||||
new Dictionary<string, string> { ["/p"] = "DR.A" },
|
||||
TimeSpan.FromSeconds(1)),
|
||||
}, CancellationToken.None);
|
||||
|
||||
await bridge.DisposeAsync();
|
||||
driver.Fire("DR.A", 999); // post-dispose event
|
||||
|
||||
sink.ReadTag("/p").StatusCode.ShouldBe(CachedTagUpstreamSource.UpstreamNotConfigured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_called_twice_throws()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
await bridge.StartAsync(Array.Empty<DriverFeed>(), CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => bridge.StartAsync(Array.Empty<DriverFeed>(), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_is_idempotent()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
await bridge.DisposeAsync();
|
||||
await bridge.DisposeAsync(); // must not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_failure_unhooks_handler_and_propagates()
|
||||
{
|
||||
var sink = new CachedTagUpstreamSource();
|
||||
var failingDriver = new ThrowingDriver();
|
||||
await using var bridge = new DriverSubscriptionBridge(sink, NullLogger<DriverSubscriptionBridge>.Instance);
|
||||
|
||||
var feeds = new[]
|
||||
{
|
||||
new DriverFeed(failingDriver,
|
||||
new Dictionary<string, string> { ["/p"] = "DR.A" },
|
||||
TimeSpan.FromSeconds(1)),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => bridge.StartAsync(feeds, CancellationToken.None));
|
||||
|
||||
// Handler should be unhooked — firing now would NPE if it wasn't (event has 0 subs).
|
||||
failingDriver.HasAnyHandlers.ShouldBeFalse(
|
||||
"handler must be removed when SubscribeAsync throws so it doesn't leak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_sink_or_logger_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new DriverSubscriptionBridge(null!, NullLogger<DriverSubscriptionBridge>.Instance));
|
||||
Should.Throw<ArgumentNullException>(() => new DriverSubscriptionBridge(new CachedTagUpstreamSource(), null!));
|
||||
}
|
||||
|
||||
private sealed class ThrowingDriver : ISubscribable
|
||||
{
|
||||
private EventHandler<DataChangeEventArgs>? _handler;
|
||||
public bool HasAnyHandlers => _handler is not null;
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange
|
||||
{
|
||||
add => _handler = (EventHandler<DataChangeEventArgs>?)Delegate.Combine(_handler, value);
|
||||
remove => _handler = (EventHandler<DataChangeEventArgs>?)Delegate.Remove(_handler, value);
|
||||
}
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(IReadOnlyList<string> _, TimeSpan __, CancellationToken ___) =>
|
||||
throw new InvalidOperationException("driver offline");
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Task #246 — covers the deterministic mapping inside <see cref="Phase7Composer"/>
|
||||
/// that turns <see cref="EquipmentNamespaceContent"/> into the path → fullRef map
|
||||
/// <see cref="DriverFeed.PathToFullRef"/> consumes. Pure function; no DI / DB needed.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ComposerMappingTests
|
||||
{
|
||||
private static UnsArea Area(string id, string name) =>
|
||||
new() { UnsAreaId = id, ClusterId = "c", Name = name, GenerationId = 1 };
|
||||
|
||||
private static UnsLine Line(string id, string areaId, string name) =>
|
||||
new() { UnsLineId = id, UnsAreaId = areaId, Name = name, GenerationId = 1 };
|
||||
|
||||
private static Equipment Eq(string id, string lineId, string name) => new()
|
||||
{
|
||||
EquipmentRowId = Guid.NewGuid(), GenerationId = 1, EquipmentId = id,
|
||||
EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv",
|
||||
UnsLineId = lineId, Name = name, MachineCode = "m",
|
||||
};
|
||||
|
||||
private static Tag T(string id, string name, string fullRef, string equipmentId) => new()
|
||||
{
|
||||
TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = id,
|
||||
DriverInstanceId = "drv", EquipmentId = equipmentId,
|
||||
Name = name, DataType = "Float32",
|
||||
AccessLevel = TagAccessLevel.Read, TagConfig = fullRef,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Maps_tag_to_UNS_path_walker_emits()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
Areas: [Area("a1", "warsaw")],
|
||||
Lines: [Line("l1", "a1", "oven-line")],
|
||||
Equipment: [Eq("e1", "l1", "oven-3")],
|
||||
Tags: [T("t1", "Temp", "DR.Temp", "e1")]);
|
||||
|
||||
var map = Phase7Composer.MapPathsToFullRefs(content);
|
||||
|
||||
map.ShouldContainKeyAndValue("/warsaw/oven-line/oven-3/Temp", "DR.Temp");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skips_tag_with_null_EquipmentId()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("a1", "warsaw")], [Line("l1", "a1", "ol")], [Eq("e1", "l1", "ov")],
|
||||
[T("t1", "Bare", "DR.Bare", null!)]); // SystemPlatform-style orphan
|
||||
|
||||
Phase7Composer.MapPathsToFullRefs(content).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skips_tag_pointing_at_unknown_Equipment()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("a1", "warsaw")], [Line("l1", "a1", "ol")], [Eq("e1", "l1", "ov")],
|
||||
[T("t1", "Lost", "DR.Lost", "e-missing")]);
|
||||
|
||||
Phase7Composer.MapPathsToFullRefs(content).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Maps_multiple_tags_under_same_equipment_distinctly()
|
||||
{
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("a1", "site")], [Line("l1", "a1", "line1")], [Eq("e1", "l1", "cell")],
|
||||
[T("t1", "Temp", "DR.T", "e1"), T("t2", "Pressure", "DR.P", "e1")]);
|
||||
|
||||
var map = Phase7Composer.MapPathsToFullRefs(content);
|
||||
|
||||
map.Count.ShouldBe(2);
|
||||
map["/site/line1/cell/Temp"].ShouldBe("DR.T");
|
||||
map["/site/line1/cell/Pressure"].ShouldBe("DR.P");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_content_yields_empty_map()
|
||||
{
|
||||
Phase7Composer.MapPathsToFullRefs(new EquipmentNamespaceContent([], [], [], []))
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// PR B.4 — pins the precedence order Phase7Composer uses to pick an
|
||||
/// <see cref="IAlarmHistorianWriter"/>:
|
||||
/// driver-provided > DI-registered > none. Driver wins so a future
|
||||
/// GalaxyDriver-as-IAlarmHistorianWriter takes the write path directly,
|
||||
/// preserving the v1 invariant where a driver that natively owns the
|
||||
/// historian client doesn't bounce through the sidecar IPC.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ComposerWriterSelectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task No_driver_no_injected_writer_returns_null()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
|
||||
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injectedWriter: null, out var source);
|
||||
|
||||
writer.ShouldBeNull();
|
||||
source.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Injected_writer_only_is_selected()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
var injected = new RecordingWriter("from-di");
|
||||
|
||||
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injected, out var source);
|
||||
|
||||
writer.ShouldBeSameAs(injected);
|
||||
source.ShouldStartWith("di:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_writer_wins_over_injected()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
var driver = new FakeDriverWithWriter("drv-1", "drv-out");
|
||||
await host.RegisterAsync(driver, driverConfigJson: "{}", CancellationToken.None);
|
||||
|
||||
var injected = new RecordingWriter("from-di");
|
||||
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injected, out var source);
|
||||
|
||||
writer.ShouldBeSameAs(driver);
|
||||
source.ShouldBe("driver:drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_driver_implementing_writer_wins()
|
||||
{
|
||||
await using var host = new DriverHost();
|
||||
var driverNoWriter = new FakeDriverWithoutWriter("drv-1");
|
||||
var driverWithWriter = new FakeDriverWithWriter("drv-2", "drv-out");
|
||||
|
||||
await host.RegisterAsync(driverNoWriter, "{}", CancellationToken.None);
|
||||
await host.RegisterAsync(driverWithWriter, "{}", CancellationToken.None);
|
||||
|
||||
var writer = Phase7Composer.SelectAlarmHistorianWriter(host, injectedWriter: null, out var source);
|
||||
|
||||
writer.ShouldBeSameAs(driverWithWriter);
|
||||
source.ShouldBe("driver:drv-2");
|
||||
}
|
||||
|
||||
private sealed class RecordingWriter : IAlarmHistorianWriter
|
||||
{
|
||||
public string Tag { get; }
|
||||
public RecordingWriter(string tag) { Tag = tag; }
|
||||
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcomes = new HistorianWriteOutcome[batch.Count];
|
||||
for (var i = 0; i < outcomes.Length; i++) outcomes[i] = HistorianWriteOutcome.Ack;
|
||||
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeDriverWithoutWriter : IDriver
|
||||
{
|
||||
public FakeDriverWithoutWriter(string id) { DriverInstanceId = id; }
|
||||
public string DriverInstanceId { get; }
|
||||
public string DriverType => "FakeNoWriter";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeDriverWithWriter : IDriver, IAlarmHistorianWriter
|
||||
{
|
||||
private readonly RecordingWriter _writer;
|
||||
public FakeDriverWithWriter(string id, string tag)
|
||||
{
|
||||
DriverInstanceId = id;
|
||||
_writer = new RecordingWriter(tag);
|
||||
}
|
||||
public string DriverInstanceId { get; }
|
||||
public string DriverType => "FakeWithWriter";
|
||||
public Task InitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ReinitializeAsync(string c, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken)
|
||||
=> _writer.WriteBatchAsync(batch, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up (task #243) — verifies the composer that maps Config DB
|
||||
/// rows to runtime engine definitions + wires up VirtualTagEngine +
|
||||
/// ScriptedAlarmEngine + historian routing.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7EngineComposerTests
|
||||
{
|
||||
private static Script ScriptRow(string id, string source) => new()
|
||||
{
|
||||
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
||||
};
|
||||
|
||||
private static VirtualTag VtRow(string id, string scriptId) => new()
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
||||
DataType = "Float32", ScriptId = scriptId,
|
||||
};
|
||||
|
||||
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
||||
AlarmType = "LimitAlarm", Severity = 500,
|
||||
MessageTemplate = "x", PredicateScriptId = scriptId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Compose_empty_rows_returns_Empty_sentinel()
|
||||
{
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts: [],
|
||||
virtualTags: [],
|
||||
scriptedAlarms: [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.ShouldBeSameAs(Phase7ComposedSources.Empty);
|
||||
result.VirtualReadable.ShouldBeNull();
|
||||
result.ScriptedAlarmReadable.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_VirtualTag_rows_returns_non_null_VirtualReadable()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||
var vtags = new[] { VtRow("vt-1", "scr-1") };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.VirtualReadable.ShouldNotBeNull();
|
||||
result.ScriptedAlarmReadable.ShouldBeNull("no alarms configured");
|
||||
result.Disposables.Count.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_ScriptedAlarm_rows_returns_non_null_ScriptedAlarmReadable()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return false;") };
|
||||
var alarms = new[] { AlarmRow("al-1", "scr-1") };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, [], alarms,
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.ScriptedAlarmReadable.ShouldNotBeNull("task #245 — alarm Active state readable");
|
||||
result.VirtualReadable.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_missing_script_reference_throws_with_actionable_message()
|
||||
{
|
||||
var vtags = new[] { VtRow("vt-1", "scr-missing") };
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
Phase7EngineComposer.Compose(
|
||||
scripts: [],
|
||||
vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance))
|
||||
.Message.ShouldContain("scr-missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_disabled_VirtualTag_is_skipped()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||
var disabled = VtRow("vt-1", "scr-1");
|
||||
disabled.Enabled = false;
|
||||
|
||||
var defs = Phase7EngineComposer.ProjectVirtualTags(
|
||||
new[] { disabled },
|
||||
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).ToList();
|
||||
|
||||
defs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectVirtualTags_maps_timer_interval_milliseconds_to_TimeSpan()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||
var vt = VtRow("vt-1", "scr-1");
|
||||
vt.TimerIntervalMs = 2500;
|
||||
|
||||
var def = Phase7EngineComposer.ProjectVirtualTags(
|
||||
new[] { vt },
|
||||
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
|
||||
|
||||
def.TimerInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectScriptedAlarms_maps_Severity_numeric_to_AlarmSeverity_bucket()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return true;") };
|
||||
|
||||
var buckets = new[] { (1, AlarmSeverity.Low), (250, AlarmSeverity.Low),
|
||||
(251, AlarmSeverity.Medium), (500, AlarmSeverity.Medium),
|
||||
(501, AlarmSeverity.High), (750, AlarmSeverity.High),
|
||||
(751, AlarmSeverity.Critical), (1000, AlarmSeverity.Critical) };
|
||||
foreach (var (input, expected) in buckets)
|
||||
{
|
||||
var row = AlarmRow("a1", "scr-1");
|
||||
row.Severity = input;
|
||||
var def = Phase7EngineComposer.ProjectScriptedAlarms(
|
||||
new[] { row },
|
||||
new Dictionary<string, Script> { ["scr-1"] = scripts[0] }).Single();
|
||||
def.Severity.ShouldBe(expected, $"severity {input} should map to {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.History;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Task #28 — Gap 5 closure: verifies that <see cref="RingBufferHistoryWriter"/>
|
||||
/// correctly records virtual-tag evaluation results and returns them via the
|
||||
/// <see cref="IHistorianDataSource"/> read interface; and that
|
||||
/// <see cref="Phase7EngineComposer.Compose"/> wires the writer and registers it
|
||||
/// with an <see cref="IHistoryRouter"/> when <c>Historize=true</c> tags are present.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RingBufferHistoryWriterTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime T1 = T0.AddSeconds(1);
|
||||
private static readonly DateTime T2 = T0.AddSeconds(2);
|
||||
private static readonly DateTime T3 = T0.AddSeconds(3);
|
||||
|
||||
private static DataValueSnapshot Snap(double value, DateTime ts) =>
|
||||
new(value, 0u, ts, ts);
|
||||
|
||||
// ===== RingBufferHistoryWriter unit tests =====
|
||||
|
||||
[Fact]
|
||||
public void Record_stores_sample_retrievable_via_GetSnapshots()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/area/line/eq/Tag1", Snap(42.0, T0));
|
||||
|
||||
var snaps = writer.GetSnapshots("/area/line/eq/Tag1");
|
||||
snaps.Length.ShouldBe(1);
|
||||
snaps[0].Value.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_multiple_samples_preserves_insertion_order()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/t", Snap(1.0, T0));
|
||||
writer.Record("/t", Snap(2.0, T1));
|
||||
writer.Record("/t", Snap(3.0, T2));
|
||||
|
||||
var snaps = writer.GetSnapshots("/t");
|
||||
snaps.Length.ShouldBe(3);
|
||||
snaps[0].Value.ShouldBe(1.0);
|
||||
snaps[1].Value.ShouldBe(2.0);
|
||||
snaps[2].Value.ShouldBe(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_evicts_oldest_when_capacity_exceeded()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter(capacity: 3);
|
||||
writer.Record("/t", Snap(1.0, T0));
|
||||
writer.Record("/t", Snap(2.0, T1));
|
||||
writer.Record("/t", Snap(3.0, T2));
|
||||
writer.Record("/t", Snap(4.0, T3)); // evicts 1.0
|
||||
|
||||
var snaps = writer.GetSnapshots("/t");
|
||||
snaps.Length.ShouldBe(3);
|
||||
snaps[0].Value.ShouldBe(2.0, "oldest evicted");
|
||||
snaps[2].Value.ShouldBe(4.0, "newest present");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Record_maintains_separate_buffers_per_tag_path()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/area/eq/TagA", Snap(10.0, T0));
|
||||
writer.Record("/area/eq/TagB", Snap(20.0, T0));
|
||||
|
||||
writer.GetSnapshots("/area/eq/TagA").Single().Value.ShouldBe(10.0);
|
||||
writer.GetSnapshots("/area/eq/TagB").Single().Value.ShouldBe(20.0);
|
||||
writer.TagCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshots_returns_empty_for_unknown_path()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.GetSnapshots("/not/a/path").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_clears_buffers_and_subsequent_Record_is_silently_ignored()
|
||||
{
|
||||
var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/t", Snap(1.0, T0));
|
||||
writer.Dispose();
|
||||
|
||||
// After dispose, Record must silently drop (no exception).
|
||||
Should.NotThrow(() => writer.Record("/t", Snap(2.0, T1)));
|
||||
// GetSnapshots post-dispose returns empty (buffers cleared).
|
||||
writer.GetSnapshots("/t").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ===== IHistorianDataSource.ReadRawAsync tests =====
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_returns_empty_for_unknown_path()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
var result = await writer.ReadRawAsync("notexists", T0, T3, 100, default);
|
||||
result.Samples.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_returns_samples_in_time_window()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/t", Snap(1.0, T0));
|
||||
writer.Record("/t", Snap(2.0, T1));
|
||||
writer.Record("/t", Snap(3.0, T2));
|
||||
|
||||
// Window [T0, T2) — T2 excluded (half-open interval).
|
||||
var result = await writer.ReadRawAsync("/t", T0, T2, 100, default);
|
||||
result.Samples.Count.ShouldBe(2);
|
||||
result.Samples[0].Value.ShouldBe(1.0);
|
||||
result.Samples[1].Value.ShouldBe(2.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_respects_maxValuesPerNode_cap()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/t", Snap(1.0, T0));
|
||||
writer.Record("/t", Snap(2.0, T1));
|
||||
writer.Record("/t", Snap(3.0, T2));
|
||||
|
||||
var result = await writer.ReadRawAsync("/t", T0, T3, maxValuesPerNode: 2, default);
|
||||
result.Samples.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadProcessedAsync_returns_empty_result()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/t", Snap(1.0, T0));
|
||||
var result = await writer.ReadProcessedAsync("/t", T0, T3, TimeSpan.FromSeconds(1),
|
||||
HistoryAggregateType.Average, default);
|
||||
result.Samples.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_returns_empty_result()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
writer.Record("/t", Snap(1.0, T0));
|
||||
var result = await writer.ReadAtTimeAsync("/t", [T0, T1], default);
|
||||
result.Samples.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_returns_empty_result()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
var result = await writer.ReadEventsAsync(null, T0, T3, 100, default);
|
||||
result.Events.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_returns_connected_non_null_snapshot()
|
||||
{
|
||||
using var writer = new RingBufferHistoryWriter();
|
||||
var health = writer.GetHealthSnapshot();
|
||||
health.ShouldNotBeNull();
|
||||
health.ProcessConnectionOpen.ShouldBeTrue("ring buffer is always available in-process");
|
||||
}
|
||||
|
||||
// ===== Phase7EngineComposer wiring tests =====
|
||||
|
||||
private static Script ScriptRow(string id, string source) => new()
|
||||
{
|
||||
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
||||
};
|
||||
|
||||
private static VirtualTag VtRow(string id, string scriptId, bool historize = false) => new()
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
||||
DataType = "Float32", ScriptId = scriptId,
|
||||
Historize = historize,
|
||||
};
|
||||
|
||||
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
||||
AlarmType = "LimitAlarm", Severity = 500,
|
||||
MessageTemplate = "x", PredicateScriptId = scriptId,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Compose_without_Historize_uses_NullHistoryWriter_and_skips_router_registration()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var scripts = new[] { ScriptRow("s1", "return 1;") };
|
||||
var vtags = new[] { VtRow("vt-1", "s1", historize: false) };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance,
|
||||
historyRouter: router);
|
||||
|
||||
// Router should not have a "virtual:" prefix entry when no Historize=true tags.
|
||||
router.Resolve(Phase7EngineComposer.VirtualTagHistoryPrefix + "vt-1").ShouldBeNull();
|
||||
result.VirtualReadable.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_with_Historize_true_registers_RingBufferHistoryWriter_in_router()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance,
|
||||
historyRouter: router);
|
||||
|
||||
// The "virtual:" prefix must resolve to a RingBufferHistoryWriter instance.
|
||||
var source = router.Resolve(Phase7EngineComposer.VirtualTagHistoryPrefix + "vt-hist");
|
||||
source.ShouldNotBeNull("router should have the ring-buffer source registered under 'virtual:' prefix");
|
||||
source.ShouldBeOfType<RingBufferHistoryWriter>();
|
||||
|
||||
result.VirtualReadable.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_with_Historize_true_but_no_router_does_not_throw()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||
|
||||
// historyRouter = null — should still work, just no registration.
|
||||
Should.NotThrow(() => Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance,
|
||||
historyRouter: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_with_Historize_true_router_already_registered_does_not_throw()
|
||||
{
|
||||
// Simulate a reload scenario where the prefix is already registered.
|
||||
using var router = new HistoryRouter();
|
||||
using var priorWriter = new RingBufferHistoryWriter();
|
||||
router.Register(Phase7EngineComposer.VirtualTagHistoryPrefix, priorWriter);
|
||||
|
||||
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||
|
||||
// Second compose call — should tolerate the duplicate without throwing.
|
||||
Should.NotThrow(() => Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance,
|
||||
historyRouter: router));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_RingBufferHistoryWriter_is_in_disposables_list()
|
||||
{
|
||||
using var router = new HistoryRouter();
|
||||
var scripts = new[] { ScriptRow("s1", "return 1.0f;") };
|
||||
var vtags = new[] { VtRow("vt-hist", "s1", historize: true) };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance,
|
||||
historyRouter: router);
|
||||
|
||||
// The RingBufferHistoryWriter must be tracked in Disposables so Phase7Composer.DisposeAsync
|
||||
// clears the ring buffer on shutdown.
|
||||
result.Disposables.ShouldContain(d => d is RingBufferHistoryWriter);
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-008 — <c>RouteScriptedAlarmMethodCalls</c> must mark a handled
|
||||
/// <see cref="CallMethodRequest"/> slot as <c>Processed = true</c> so the stack's
|
||||
/// <c>CustomNodeManager2.Call</c> skips it. The pre-fix code relied on the slot's
|
||||
/// <c>errors[i]</c> being <c>ServiceResult.Good</c>, but the SDK's actual skip predicate is
|
||||
/// <see cref="CallMethodRequest.Processed"/>; without setting it, the stack's built-in
|
||||
/// Part 9 Acknowledge / Confirm handler would also fire, producing a double transition.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmMethodRoutingProcessedFlagTests
|
||||
{
|
||||
private static ScriptedAlarmEngine BuildActiveEngine(string alarmId)
|
||||
{
|
||||
var upstream = new CachedTagUpstreamSource();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(logger);
|
||||
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||
var defs = new List<ScriptedAlarmDefinition>
|
||||
{
|
||||
new(AlarmId: alarmId,
|
||||
EquipmentPath: "/eq",
|
||||
AlarmName: alarmId,
|
||||
Kind: AlarmKind.LimitAlarm,
|
||||
Severity: AlarmSeverity.Medium,
|
||||
MessageTemplate: "msg",
|
||||
PredicateScriptSource: "return true;"),
|
||||
};
|
||||
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static ScriptedAlarmEngine BuildEngine(string alarmId)
|
||||
{
|
||||
var upstream = new CachedTagUpstreamSource();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(logger);
|
||||
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||
var defs = new List<ScriptedAlarmDefinition>
|
||||
{
|
||||
new(AlarmId: alarmId,
|
||||
EquipmentPath: "/eq",
|
||||
AlarmName: alarmId,
|
||||
Kind: AlarmKind.LimitAlarm,
|
||||
Severity: AlarmSeverity.Medium,
|
||||
MessageTemplate: "msg",
|
||||
PredicateScriptSource: "return false;"),
|
||||
};
|
||||
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static CallMethodRequest AcknowledgeRequest(string conditionNodeId)
|
||||
=> new()
|
||||
{
|
||||
ObjectId = new NodeId(conditionNodeId, 2),
|
||||
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||
InputArguments =
|
||||
{
|
||||
new Variant(new byte[] { 1, 2, 3 }),
|
||||
new Variant(new LocalizedText("ack-comment")),
|
||||
},
|
||||
};
|
||||
|
||||
private static CallMethodRequest AddCommentRequest(string conditionNodeId)
|
||||
=> new()
|
||||
{
|
||||
ObjectId = new NodeId(conditionNodeId, 2),
|
||||
MethodId = MethodIds.ConditionType_AddComment,
|
||||
InputArguments =
|
||||
{
|
||||
new Variant(new byte[] { 1, 2, 3 }),
|
||||
new Variant(new LocalizedText("comment-text")),
|
||||
},
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Handled_Acknowledge_marks_Processed_true_so_baseCall_skips_the_slot()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["al-1.Condition"] = "al-1",
|
||||
};
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
|
||||
|
||||
calls[0].Processed.ShouldBeTrue(
|
||||
"CustomNodeManager2.Call/CallInternalAsync skips slots with Processed=true. "
|
||||
+ "Without this flag, base.Call would re-dispatch the Acknowledge to the stack's "
|
||||
+ "built-in Part 9 handler and the engine would observe a double transition.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handled_AddComment_marks_Processed_true()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
var calls = new List<CallMethodRequest> { AddCommentRequest("al-1.Condition") };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["al-1.Condition"] = "al-1",
|
||||
};
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
|
||||
|
||||
calls[0].Processed.ShouldBeTrue("AddComment handled by the engine must not re-dispatch via base.Call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Engine_error_path_also_marks_Processed_so_baseCall_does_not_re_run_the_method()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
// Index maps to an alarm id the engine doesn't know — engine throws
|
||||
// ArgumentException, helper sets errors[i] = BadInvalidArgument.
|
||||
AcknowledgeRequest("al-1.Condition"),
|
||||
};
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["al-1.Condition"] = "al-NOT-IN-ENGINE",
|
||||
};
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
new NamedIdentity("ops-user"), calls, results, errors, engine, index);
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue("engine error path");
|
||||
calls[0].Processed.ShouldBeTrue(
|
||||
"even when the engine returns Bad, the slot was handled — base.Call must not "
|
||||
+ "re-dispatch the method against the OPC UA built-in handler.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unhandled_slot_leaves_Processed_false_so_baseCall_drives_it()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
var genericMethod = new CallMethodRequest
|
||||
{
|
||||
ObjectId = new NodeId("some-driver-method", 2),
|
||||
MethodId = new NodeId("driver-method", 2),
|
||||
};
|
||||
var calls = new List<CallMethodRequest> { genericMethod };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
new NamedIdentity("ops-user"), calls, results, errors, engine,
|
||||
conditionIdToAlarmId: new Dictionary<string, string>());
|
||||
|
||||
calls[0].Processed.ShouldBeFalse("non-alarm methods must fall through to base.Call");
|
||||
errors[0].ShouldBeNull("unhandled slot's error must stay null for the base implementation");
|
||||
}
|
||||
|
||||
private sealed class NamedIdentity(string displayName) : UserIdentity(displayName, "") { }
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
using CoreAlarmConditionState = ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.AlarmConditionState;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Task #24 — Gap 1 of phase-7-status.md. Covers
|
||||
/// <see cref="DriverNodeManager.RouteScriptedAlarmMethodCalls"/> which intercepts
|
||||
/// OPC UA Part 9 Acknowledge / Confirm method invocations on scripted alarm condition
|
||||
/// nodes and routes them to <see cref="ScriptedAlarmEngine"/>, and the
|
||||
/// <see cref="Phase7ComposedSources.AlarmEngine"/> property added to expose the
|
||||
/// engine through the composition chain.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmMethodRoutingTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase7ComposedSources — AlarmEngine property
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Compose_ScriptedAlarm_rows_exposes_AlarmEngine()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return false;") };
|
||||
var alarms = new[] { AlarmRow("al-1", "scr-1") };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, [], alarms,
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.AlarmEngine.ShouldNotBeNull("engine is exposed so the server can route method calls");
|
||||
result.ScriptedAlarmReadable.ShouldNotBeNull();
|
||||
result.Disposables.Count.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_empty_rows_AlarmEngine_is_null()
|
||||
{
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts: [],
|
||||
virtualTags: [],
|
||||
scriptedAlarms: [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.ShouldBeSameAs(Phase7ComposedSources.Empty);
|
||||
result.AlarmEngine.ShouldBeNull("empty composition returns the Empty sentinel with all-null engines");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_VirtualTag_only_AlarmEngine_is_null()
|
||||
{
|
||||
var scripts = new[] { ScriptRow("scr-1", "return 1;") };
|
||||
var vtags = new[] { VtRow("vt-1", "scr-1") };
|
||||
|
||||
var result = Phase7EngineComposer.Compose(
|
||||
scripts, vtags, [],
|
||||
upstream: new CachedTagUpstreamSource(),
|
||||
alarmStateStore: new InMemoryAlarmStateStore(),
|
||||
historianSink: NullAlarmHistorianSink.Instance,
|
||||
rootScriptLogger: new LoggerConfiguration().CreateLogger(),
|
||||
loggerFactory: NullLoggerFactory.Instance);
|
||||
|
||||
result.AlarmEngine.ShouldBeNull("no scripted alarms → alarm engine is null");
|
||||
result.VirtualReadable.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// RouteScriptedAlarmMethodCalls — pure-function dispatch kernel
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a loaded ScriptedAlarmEngine with the given alarm IDs.
|
||||
/// All predicates return <c>false</c> so the alarm starts Inactive.
|
||||
/// </summary>
|
||||
private static ScriptedAlarmEngine BuildEngine(params string[] alarmIds)
|
||||
{
|
||||
var upstream = new CachedTagUpstreamSource();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(logger);
|
||||
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||
var defs = alarmIds.Select(id => new ScriptedAlarmDefinition(
|
||||
AlarmId: id,
|
||||
EquipmentPath: "/eq",
|
||||
AlarmName: id,
|
||||
Kind: AlarmKind.LimitAlarm,
|
||||
Severity: AlarmSeverity.Medium,
|
||||
MessageTemplate: "msg",
|
||||
PredicateScriptSource: "return false;")).ToList();
|
||||
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
return engine;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a loaded ScriptedAlarmEngine where the named alarm starts Active
|
||||
/// (predicate = return true) so subsequent Acknowledge tests have an
|
||||
/// Unacknowledged state to advance.
|
||||
/// </summary>
|
||||
private static ScriptedAlarmEngine BuildActiveEngine(string alarmId)
|
||||
{
|
||||
var upstream = new CachedTagUpstreamSource();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(logger);
|
||||
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||
var defs = new List<ScriptedAlarmDefinition>
|
||||
{
|
||||
new(AlarmId: alarmId,
|
||||
EquipmentPath: "/eq",
|
||||
AlarmName: alarmId,
|
||||
Kind: AlarmKind.LimitAlarm,
|
||||
Severity: AlarmSeverity.Medium,
|
||||
MessageTemplate: "msg",
|
||||
PredicateScriptSource: "return true;"),
|
||||
};
|
||||
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static IUserIdentity? MakeIdentity(string? displayName)
|
||||
=> displayName is null ? null : new NamedUserIdentity(displayName);
|
||||
|
||||
private static CallMethodRequest AcknowledgeRequest(string conditionNodeId, string? comment = null)
|
||||
=> new()
|
||||
{
|
||||
ObjectId = new NodeId(conditionNodeId, 2),
|
||||
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||
InputArguments = new VariantCollection
|
||||
{
|
||||
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
||||
new Variant(new LocalizedText(comment ?? string.Empty)),
|
||||
},
|
||||
};
|
||||
|
||||
private static CallMethodRequest ConfirmRequest(string conditionNodeId, string? comment = null)
|
||||
=> new()
|
||||
{
|
||||
ObjectId = new NodeId(conditionNodeId, 2),
|
||||
MethodId = MethodIds.AcknowledgeableConditionType_Confirm,
|
||||
InputArguments = new VariantCollection
|
||||
{
|
||||
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
||||
new Variant(new LocalizedText(comment ?? string.Empty)),
|
||||
},
|
||||
};
|
||||
|
||||
private static CallMethodRequest AddCommentRequest(string conditionNodeId, string comment)
|
||||
=> new()
|
||||
{
|
||||
ObjectId = new NodeId(conditionNodeId, 2),
|
||||
MethodId = MethodIds.ConditionType_AddComment,
|
||||
InputArguments = new VariantCollection
|
||||
{
|
||||
new Variant(new byte[] { 1, 2, 3 }), // EventId (ByteString) — ignored
|
||||
new Variant(new LocalizedText(comment)),
|
||||
},
|
||||
};
|
||||
|
||||
private static CallMethodRequest GenericRequest(string objectNodeId)
|
||||
=> new()
|
||||
{
|
||||
ObjectId = new NodeId(objectNodeId, 2),
|
||||
MethodId = new NodeId("driver-method", 2),
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> Index(params (string condId, string alarmId)[] entries)
|
||||
=> entries.ToDictionary(e => e.condId, e => e.alarmId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ---- no-op paths -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void No_index_entries_leaves_all_slots_untouched()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
AcknowledgeRequest("al-1.Condition"),
|
||||
};
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("alice"), calls, results, errors, engine,
|
||||
conditionIdToAlarmId: new Dictionary<string, string>());
|
||||
|
||||
errors[0].ShouldBeNull("no matching entry → slot left for base.Call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_alarm_method_id_is_ignored()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
GenericRequest("al-1.Condition"),
|
||||
};
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||
|
||||
errors[0].ShouldBeNull("non-Acknowledge/Confirm methods pass through untouched");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Already_errored_slot_is_skipped()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
AcknowledgeRequest("al-1.Condition"),
|
||||
};
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var priorError = new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
var errors = new List<ServiceResult> { priorError };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||
|
||||
// Pre-populated bad error must not be overwritten.
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
// ---- Acknowledge -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_on_active_alarm_advances_engine_state()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
|
||||
// Sanity: alarm must start unacknowledged after activation.
|
||||
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
|
||||
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition", "looks ok") };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
||||
|
||||
errors[0].ShouldNotBeNull();
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge succeeded");
|
||||
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
engine.GetState("al-1")!.LastAckUser.ShouldBe("ops-user");
|
||||
engine.GetState("al-1")!.LastAckComment.ShouldBe("looks ok");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_uses_opcua_client_as_fallback_when_identity_is_null()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
|
||||
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
// Pass null identity (anonymous session).
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
userIdentity: null, calls, results, errors, engine, index);
|
||||
|
||||
engine.GetState("al-1")!.LastAckUser.ShouldBe("opcua-client");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_with_no_input_arguments_uses_null_comment()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
// Build a request without InputArguments to simulate a client that omits the comment.
|
||||
var requestNoArgs = new CallMethodRequest
|
||||
{
|
||||
ObjectId = new NodeId("al-1.Condition", 2),
|
||||
MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge,
|
||||
};
|
||||
|
||||
var calls = new List<CallMethodRequest> { requestNoArgs };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
// Should not throw — comment defaults to null.
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Acknowledge without comment succeeds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_marks_slot_result_as_Good_and_error_as_Good()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
|
||||
var calls = new List<CallMethodRequest> { AcknowledgeRequest("al-1.Condition") };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||
|
||||
results[0].StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
errors[0].ShouldBe(ServiceResult.Good);
|
||||
}
|
||||
|
||||
// ---- Confirm -----------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Confirm_on_alarm_with_unconfirmed_state_advances_state()
|
||||
{
|
||||
// Build an alarm pre-seeded as Inactive + Acknowledged + Unconfirmed so
|
||||
// ApplyConfirm has a valid transition to execute.
|
||||
var store = new InMemoryAlarmStateStore();
|
||||
var upstream = new CachedTagUpstreamSource();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(logger);
|
||||
var engine = new ScriptedAlarmEngine(upstream, store, factory, logger);
|
||||
|
||||
var seedState = CoreAlarmConditionState.Fresh("confirm-alarm", DateTime.UtcNow) with
|
||||
{
|
||||
Active = AlarmActiveState.Inactive,
|
||||
Acked = AlarmAckedState.Acknowledged,
|
||||
Confirmed = AlarmConfirmedState.Unconfirmed,
|
||||
};
|
||||
store.SaveAsync(seedState, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var defs = new List<ScriptedAlarmDefinition>
|
||||
{
|
||||
new("confirm-alarm", "/eq", "confirm-alarm", AlarmKind.LimitAlarm,
|
||||
AlarmSeverity.Low, "msg", "return false;"),
|
||||
};
|
||||
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
|
||||
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
ConfirmRequest("confirm-alarm.Condition", "all clear"),
|
||||
};
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("confirm-alarm.Condition", "confirm-alarm"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeFalse("Confirm succeeded");
|
||||
engine.GetState("confirm-alarm")!.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
||||
engine.GetState("confirm-alarm")!.LastConfirmUser.ShouldBe("ops-user");
|
||||
}
|
||||
|
||||
// ---- AddComment --------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddComment_appends_comment_to_engine_state()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
|
||||
var calls = new List<CallMethodRequest> { AddCommentRequest("al-1.Condition", "checked the line") };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeFalse("AddComment handled");
|
||||
results[0].StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
var last = engine.GetState("al-1")!.Comments[^1];
|
||||
last.Kind.ShouldBe("AddComment");
|
||||
last.Text.ShouldBe("checked the line");
|
||||
last.User.ShouldBe("ops-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddComment_with_empty_text_returns_BadInvalidArgument()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
|
||||
// The Part 9 state machine rejects an empty comment — surfaced as BadInvalidArgument.
|
||||
var calls = new List<CallMethodRequest> { AddCommentRequest("al-1.Condition", "") };
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var index = Index(("al-1.Condition", "al-1"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("ops-user"), calls, results, errors, engine, index);
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||
}
|
||||
|
||||
// ---- Mixed batches -----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Mixed_batch_handles_each_slot_independently()
|
||||
{
|
||||
using var engine = BuildActiveEngine("al-1");
|
||||
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
AcknowledgeRequest("al-1.Condition"), // scripted alarm → handled
|
||||
GenericRequest("some-driver-method"), // non-alarm → pass through
|
||||
AcknowledgeRequest("unknown-alarm.Condition"), // not in index → pass through
|
||||
};
|
||||
var results = Enumerable.Range(0, 3).Select(_ => new CallMethodResult()).ToList();
|
||||
var errors = new List<ServiceResult> { null!, null!, null! };
|
||||
var index = Index(("al-1.Condition", "al-1")); // only one entry
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||
|
||||
// Slot 0: Acknowledge on known scripted alarm → handled with Good result.
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeFalse("scripted alarm Acknowledge handled");
|
||||
engine.GetState("al-1")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
|
||||
// Slot 1: Generic method → left null for base.Call.
|
||||
errors[1].ShouldBeNull("generic method left for base.Call");
|
||||
|
||||
// Slot 2: Unknown alarm id → left null for base.Call.
|
||||
errors[2].ShouldBeNull("unknown condition id left for base.Call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_alarm_id_in_engine_returns_BadInvalidArgument()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
|
||||
// The index says al-999 maps to "al-999-engine" but the engine has no such alarm.
|
||||
var calls = new List<CallMethodRequest>
|
||||
{
|
||||
AcknowledgeRequest("al-999.Condition"),
|
||||
};
|
||||
var results = new List<CallMethodResult> { new CallMethodResult() };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
// Put a deliberately wrong alarmId in the index (engine will throw ArgumentException).
|
||||
var index = Index(("al-999.Condition", "al-999-not-in-engine"));
|
||||
|
||||
DriverNodeManager.RouteScriptedAlarmMethodCalls(
|
||||
MakeIdentity("alice"), calls, results, errors, engine, index);
|
||||
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue("unknown alarm in engine → error result");
|
||||
errors[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||
}
|
||||
|
||||
// ---- Shelve routing (Task #24 follow-up) -------------------------------
|
||||
|
||||
[Fact]
|
||||
public void InvokeEngineShelve_oneshot_shelves_engine_state()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
|
||||
var result = DriverNodeManager.InvokeEngineShelve(
|
||||
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
||||
|
||||
ServiceResult.IsBad(result).ShouldBeFalse("OneShotShelve succeeds");
|
||||
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokeEngineShelve_timed_shelves_engine_state()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
|
||||
// shelvingTime is a Duration in ms — InvokeEngineShelve adds it to UtcNow.
|
||||
var result = DriverNodeManager.InvokeEngineShelve(
|
||||
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 60_000, logger: null);
|
||||
|
||||
ServiceResult.IsBad(result).ShouldBeFalse("TimedShelve succeeds");
|
||||
var state = engine.GetState("al-1")!;
|
||||
state.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||
state.Shelving.UnshelveAtUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokeEngineShelve_unshelve_clears_engine_state()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
DriverNodeManager.InvokeEngineShelve(
|
||||
engine, "al-1", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
||||
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
|
||||
|
||||
var result = DriverNodeManager.InvokeEngineShelve(
|
||||
engine, "al-1", "ops-user", shelving: false, oneShot: false, shelvingTime: 0, logger: null);
|
||||
|
||||
ServiceResult.IsBad(result).ShouldBeFalse("Unshelve succeeds");
|
||||
engine.GetState("al-1")!.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokeEngineShelve_timed_with_non_positive_duration_returns_BadInvalidArgument()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
|
||||
// A TimedShelve resolving to an unshelve time at-or-before now is rejected by the
|
||||
// engine's Part 9 state machine (ArgumentOutOfRangeException → BadInvalidArgument).
|
||||
var result = DriverNodeManager.InvokeEngineShelve(
|
||||
engine, "al-1", "ops-user", shelving: true, oneShot: false, shelvingTime: 0, logger: null);
|
||||
|
||||
ServiceResult.IsBad(result).ShouldBeTrue();
|
||||
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokeEngineShelve_unknown_alarm_returns_BadInvalidArgument()
|
||||
{
|
||||
using var engine = BuildEngine("al-1");
|
||||
|
||||
var result = DriverNodeManager.InvokeEngineShelve(
|
||||
engine, "not-an-alarm", "ops-user", shelving: true, oneShot: true, shelvingTime: 0, logger: null);
|
||||
|
||||
ServiceResult.IsBad(result).ShouldBeTrue("unknown alarm id → error result");
|
||||
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadInvalidArgument);
|
||||
}
|
||||
|
||||
// ---- Phase7ComposedSources helpers -------------------------------------
|
||||
|
||||
private static Script ScriptRow(string id, string source) => new()
|
||||
{
|
||||
ScriptRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h",
|
||||
};
|
||||
|
||||
private static VirtualTag VtRow(string id, string scriptId) => new()
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = id, EquipmentId = "eq-1", Name = id,
|
||||
DataType = "Float32", ScriptId = scriptId,
|
||||
};
|
||||
|
||||
private static ScriptedAlarm AlarmRow(string id, string scriptId) => new()
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = id, EquipmentId = "eq-1", Name = id,
|
||||
AlarmType = "LimitAlarm", Severity = 500,
|
||||
MessageTemplate = "x", PredicateScriptId = scriptId,
|
||||
};
|
||||
|
||||
// ---- Fake user identity ------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Simple <see cref="UserIdentity"/> with a display name for unit testing.
|
||||
/// Uses the <c>UserIdentity(username, password)</c> constructor so the base-class
|
||||
/// <see cref="UserIdentity.DisplayName"/> property returns the supplied name when
|
||||
/// accessed through the <see cref="IUserIdentity"/> interface.
|
||||
/// The real production identity is <c>OtOpcUaServer.RoleBasedIdentity</c> which
|
||||
/// populates DisplayName from the LDAP authentication result.
|
||||
/// </summary>
|
||||
private sealed class NamedUserIdentity(string displayName) : UserIdentity(displayName, "") { }
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Phase7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests.Phase7;
|
||||
|
||||
/// <summary>
|
||||
/// Task #245 — covers the IReadable adapter that surfaces each scripted alarm's
|
||||
/// live <c>ActiveState</c> so OPC UA variable reads on Source=ScriptedAlarm nodes
|
||||
/// return the predicate truth instead of BadNotFound.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmReadableTests
|
||||
{
|
||||
private static (ScriptedAlarmEngine engine, CachedTagUpstreamSource upstream) BuildEngineWith(
|
||||
params (string alarmId, string predicateSource)[] alarms)
|
||||
{
|
||||
var upstream = new CachedTagUpstreamSource();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(logger);
|
||||
var engine = new ScriptedAlarmEngine(upstream, new InMemoryAlarmStateStore(), factory, logger);
|
||||
var defs = alarms.Select(a => new ScriptedAlarmDefinition(
|
||||
AlarmId: a.alarmId,
|
||||
EquipmentPath: "/eq",
|
||||
AlarmName: a.alarmId,
|
||||
Kind: AlarmKind.LimitAlarm,
|
||||
Severity: AlarmSeverity.Medium,
|
||||
MessageTemplate: "x",
|
||||
PredicateScriptSource: a.predicateSource)).ToList();
|
||||
engine.LoadAsync(defs, CancellationToken.None).GetAwaiter().GetResult();
|
||||
return (engine, upstream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_return_false_for_newly_loaded_alarm_with_inactive_predicate()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(("a1", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
var result = await readable.ReadAsync(["a1"], CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(1);
|
||||
result[0].Value.ShouldBe(false);
|
||||
result[0].StatusCode.ShouldBe(0u, "Good quality when the engine has state");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_return_true_when_predicate_evaluates_to_active()
|
||||
{
|
||||
var (engine, upstream) = BuildEngineWith(
|
||||
("tempAlarm", "return ctx.GetTag(\"/Site/Line/Cell/Temp\").Value is double d && d > 100;"));
|
||||
using var _e = engine;
|
||||
|
||||
// Seed the upstream value + nudge the engine so the alarm transitions to Active.
|
||||
upstream.Push("/Site/Line/Cell/Temp",
|
||||
new DataValueSnapshot(150.0, 0u, DateTime.UtcNow, DateTime.UtcNow));
|
||||
|
||||
// Allow the engine's change-driven cascade to run.
|
||||
await Task.Delay(50);
|
||||
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
var result = await readable.ReadAsync(["tempAlarm"], CancellationToken.None);
|
||||
|
||||
result[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_return_BadNodeIdUnknown_for_missing_alarm()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(("a1", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
var result = await readable.ReadAsync(["a-not-loaded"], CancellationToken.None);
|
||||
|
||||
result[0].Value.ShouldBeNull();
|
||||
result[0].StatusCode.ShouldBe(0x80340000u,
|
||||
"BadNodeIdUnknown surfaces a misconfiguration, not a silent false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_batch_round_trip_preserves_order()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(
|
||||
("a1", "return false;"),
|
||||
("a2", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
var result = await readable.ReadAsync(["a2", "missing", "a1"], CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result[0].Value.ShouldBe(false); // a2
|
||||
result[1].StatusCode.ShouldBe(0x80340000u); // missing
|
||||
result[2].Value.ShouldBe(false); // a1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_engine_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptedAlarmReadable(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_fullReferences_rejected()
|
||||
{
|
||||
var (engine, _) = BuildEngineWith(("a1", "return false;"));
|
||||
using var _e = engine;
|
||||
var readable = new ScriptedAlarmReadable(engine);
|
||||
|
||||
await Should.ThrowAsync<ArgumentNullException>(
|
||||
() => readable.ReadAsync(null!, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RecoveryStateManagerTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
public DateTime Utc { get; set; } = T0;
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverFaulted_DwellIsAutomaticallyMet()
|
||||
{
|
||||
var mgr = new RecoveryStateManager();
|
||||
mgr.IsDwellMet().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterFault_Only_IsDwellMet_Returns_True_ButCallerDoesntQueryDuringFaulted()
|
||||
{
|
||||
// Documented semantics: IsDwellMet is only consulted when selfHealthy=true (i.e. the
|
||||
// node has recovered into Healthy). During Faulted the coordinator short-circuits on
|
||||
// the self-health check and never calls IsDwellMet. So returning true here is harmless;
|
||||
// the test captures the intent so a future "return false during Faulted" tweak has to
|
||||
// deliberately change this test first.
|
||||
var mgr = new RecoveryStateManager();
|
||||
mgr.MarkFaulted();
|
||||
mgr.IsDwellMet().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterRecovery_NoWitness_DwellNotMet_EvenAfterElapsed()
|
||||
{
|
||||
var clock = new FakeTimeProvider();
|
||||
var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock);
|
||||
mgr.MarkFaulted();
|
||||
mgr.MarkRecovered();
|
||||
clock.Utc = T0.AddSeconds(120);
|
||||
|
||||
mgr.IsDwellMet().ShouldBeFalse("dwell elapsed but no publish witness — must NOT escape Recovering band");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterRecovery_WitnessButTooSoon_DwellNotMet()
|
||||
{
|
||||
var clock = new FakeTimeProvider();
|
||||
var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock);
|
||||
mgr.MarkFaulted();
|
||||
mgr.MarkRecovered();
|
||||
mgr.RecordPublishWitness();
|
||||
clock.Utc = T0.AddSeconds(30);
|
||||
|
||||
mgr.IsDwellMet().ShouldBeFalse("witness ok but dwell 30s < 60s");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterRecovery_Witness_And_DwellElapsed_Met()
|
||||
{
|
||||
var clock = new FakeTimeProvider();
|
||||
var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock);
|
||||
mgr.MarkFaulted();
|
||||
mgr.MarkRecovered();
|
||||
mgr.RecordPublishWitness();
|
||||
clock.Utc = T0.AddSeconds(61);
|
||||
|
||||
mgr.IsDwellMet().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReFault_ResetsWitness_AndDwellClock()
|
||||
{
|
||||
var clock = new FakeTimeProvider();
|
||||
var mgr = new RecoveryStateManager(dwellTime: TimeSpan.FromSeconds(60), timeProvider: clock);
|
||||
mgr.MarkFaulted();
|
||||
mgr.MarkRecovered();
|
||||
mgr.RecordPublishWitness();
|
||||
clock.Utc = T0.AddSeconds(61);
|
||||
mgr.IsDwellMet().ShouldBeTrue();
|
||||
|
||||
mgr.MarkFaulted();
|
||||
mgr.MarkRecovered();
|
||||
clock.Utc = T0.AddSeconds(100); // re-entered Recovering, no new witness
|
||||
mgr.IsDwellMet().ShouldBeFalse("new recovery needs its own witness");
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RedundancyStatePublisherTests : IDisposable
|
||||
{
|
||||
private readonly OtOpcUaConfigDbContext _db;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
|
||||
public RedundancyStatePublisherTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"redundancy-publisher-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
_db = new OtOpcUaConfigDbContext(options);
|
||||
_dbFactory = new DbContextFactory(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private sealed class DbContextFactory(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||
: IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => new(options);
|
||||
}
|
||||
|
||||
private async Task<RedundancyCoordinator> SeedAndInitialize(string selfNodeId, params (string id, RedundancyRole role, string appUri)[] nodes)
|
||||
{
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = "c1",
|
||||
Name = "Warsaw-West",
|
||||
Enterprise = "zb",
|
||||
Site = "warsaw-west",
|
||||
RedundancyMode = nodes.Length == 1 ? RedundancyMode.None : RedundancyMode.Warm,
|
||||
CreatedBy = "test",
|
||||
};
|
||||
_db.ServerClusters.Add(cluster);
|
||||
foreach (var (id, role, appUri) in nodes)
|
||||
{
|
||||
_db.ClusterNodes.Add(new ClusterNode
|
||||
{
|
||||
NodeId = id,
|
||||
ClusterId = "c1",
|
||||
RedundancyRole = role,
|
||||
Host = id.ToLowerInvariant(),
|
||||
ApplicationUri = appUri,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, selfNodeId, "c1");
|
||||
await coordinator.InitializeAsync(CancellationToken.None);
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BeforeInit_Publishes_NoData()
|
||||
{
|
||||
// Coordinator not initialized — current topology is null.
|
||||
var coordinator = new RedundancyCoordinator(_dbFactory, NullLogger<RedundancyCoordinator>.Instance, "A", "c1");
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker());
|
||||
|
||||
var snap = publisher.ComputeAndPublish();
|
||||
|
||||
snap.Band.ShouldBe(ServiceLevelBand.NoData);
|
||||
snap.Value.ShouldBe((byte)1);
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthoritativePrimary_WhenHealthyAndPeerReachable()
|
||||
{
|
||||
var coordinator = await SeedAndInitialize("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var peers = new PeerReachabilityTracker();
|
||||
peers.Update("B", PeerReachability.FullyHealthy);
|
||||
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
|
||||
|
||||
var snap = publisher.ComputeAndPublish();
|
||||
|
||||
snap.Value.ShouldBe((byte)255);
|
||||
snap.Band.ShouldBe(ServiceLevelBand.AuthoritativePrimary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsolatedPrimary_WhenPeerUnreachable_RetainsAuthority()
|
||||
{
|
||||
var coordinator = await SeedAndInitialize("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var peers = new PeerReachabilityTracker();
|
||||
peers.Update("B", PeerReachability.Unknown);
|
||||
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
|
||||
|
||||
var snap = publisher.ComputeAndPublish();
|
||||
|
||||
snap.Value.ShouldBe((byte)230);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MidApply_WhenLeaseOpen_Dominates()
|
||||
{
|
||||
var coordinator = await SeedAndInitialize("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var leases = new ApplyLeaseRegistry();
|
||||
var peers = new PeerReachabilityTracker();
|
||||
peers.Update("B", PeerReachability.FullyHealthy);
|
||||
|
||||
await using var lease = leases.BeginApplyLease(1, Guid.NewGuid());
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, leases, new RecoveryStateManager(), peers);
|
||||
|
||||
var snap = publisher.ComputeAndPublish();
|
||||
|
||||
snap.Value.ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelfUnhealthy_Returns_NoData()
|
||||
{
|
||||
var coordinator = await SeedAndInitialize("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var peers = new PeerReachabilityTracker();
|
||||
peers.Update("B", PeerReachability.FullyHealthy);
|
||||
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers,
|
||||
selfHealthy: () => false);
|
||||
|
||||
var snap = publisher.ComputeAndPublish();
|
||||
|
||||
snap.Value.ShouldBe((byte)1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnStateChanged_FiresOnly_OnValueChange()
|
||||
{
|
||||
var coordinator = await SeedAndInitialize("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var peers = new PeerReachabilityTracker();
|
||||
peers.Update("B", PeerReachability.FullyHealthy);
|
||||
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
|
||||
|
||||
var emitCount = 0;
|
||||
byte? lastEmitted = null;
|
||||
publisher.OnStateChanged += snap => { emitCount++; lastEmitted = snap.Value; };
|
||||
|
||||
publisher.ComputeAndPublish(); // first tick — emits 255 since _lastByte was seeded at 255; no change
|
||||
peers.Update("B", PeerReachability.Unknown);
|
||||
publisher.ComputeAndPublish(); // 255 → 230 transition — emits
|
||||
publisher.ComputeAndPublish(); // still 230 — no emit
|
||||
|
||||
emitCount.ShouldBe(1);
|
||||
lastEmitted.ShouldBe((byte)230);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnServerUriArrayChanged_FiresOnce_PerTopology()
|
||||
{
|
||||
var coordinator = await SeedAndInitialize("A",
|
||||
("A", RedundancyRole.Primary, "urn:A"),
|
||||
("B", RedundancyRole.Secondary, "urn:B"));
|
||||
var peers = new PeerReachabilityTracker();
|
||||
peers.Update("B", PeerReachability.FullyHealthy);
|
||||
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), peers);
|
||||
|
||||
var emits = new List<IReadOnlyList<string>>();
|
||||
publisher.OnServerUriArrayChanged += arr => emits.Add(arr);
|
||||
|
||||
publisher.ComputeAndPublish();
|
||||
publisher.ComputeAndPublish();
|
||||
publisher.ComputeAndPublish();
|
||||
|
||||
emits.Count.ShouldBe(1, "ServerUriArray event is edge-triggered on topology content change");
|
||||
emits[0].ShouldBe(["urn:A", "urn:B"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Standalone_Cluster_IsAuthoritative_When_Healthy()
|
||||
{
|
||||
var coordinator = await SeedAndInitialize("A",
|
||||
("A", RedundancyRole.Standalone, "urn:A"));
|
||||
var publisher = new RedundancyStatePublisher(
|
||||
coordinator, new ApplyLeaseRegistry(), new RecoveryStateManager(), new PeerReachabilityTracker());
|
||||
|
||||
var snap = publisher.ComputeAndPublish();
|
||||
|
||||
snap.Value.ShouldBe((byte)255);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ResilienceStatusPublisherHostedServiceTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private sealed class FakeClock : TimeProvider
|
||||
{
|
||||
public DateTime Utc { get; set; } = T0;
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private sealed class InMemoryDbContextFactory : IDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
private readonly DbContextOptions<OtOpcUaConfigDbContext> _options;
|
||||
public InMemoryDbContextFactory(string dbName)
|
||||
{
|
||||
_options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.Options;
|
||||
}
|
||||
public OtOpcUaConfigDbContext CreateDbContext() => new(_options);
|
||||
}
|
||||
|
||||
private readonly string _dbName = $"resilience-pub-{Guid.NewGuid():N}";
|
||||
private readonly InMemoryDbContextFactory _factory;
|
||||
private readonly OtOpcUaConfigDbContext _readCtx;
|
||||
|
||||
public ResilienceStatusPublisherHostedServiceTests()
|
||||
{
|
||||
_factory = new InMemoryDbContextFactory(_dbName);
|
||||
_readCtx = _factory.CreateDbContext();
|
||||
}
|
||||
|
||||
public void Dispose() => _readCtx.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyTracker_Tick_NoOp_NoRowsWritten()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var host = new ResilienceStatusPublisherHostedService(
|
||||
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
|
||||
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
|
||||
host.TickCount.ShouldBe(1);
|
||||
(await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleHost_OnePairWithCounters_UpsertsNewRow()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordFailure("drv-1", "plc-a", T0);
|
||||
tracker.RecordFailure("drv-1", "plc-a", T0);
|
||||
tracker.RecordBreakerOpen("drv-1", "plc-a", T0.AddSeconds(1));
|
||||
|
||||
var host = new ResilienceStatusPublisherHostedService(
|
||||
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance,
|
||||
timeProvider: clock);
|
||||
|
||||
clock.Utc = T0.AddSeconds(2);
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
|
||||
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
|
||||
row.DriverInstanceId.ShouldBe("drv-1");
|
||||
row.HostName.ShouldBe("plc-a");
|
||||
row.ConsecutiveFailures.ShouldBe(2);
|
||||
row.LastCircuitBreakerOpenUtc.ShouldBe(T0.AddSeconds(1));
|
||||
row.LastSampledUtc.ShouldBe(T0.AddSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondTick_UpdatesExistingRow_InPlace()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordFailure("drv-1", "plc-a", T0);
|
||||
|
||||
var host = new ResilienceStatusPublisherHostedService(
|
||||
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance,
|
||||
timeProvider: clock);
|
||||
|
||||
clock.Utc = T0.AddSeconds(5);
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
|
||||
// Second tick: success resets the counter.
|
||||
tracker.RecordSuccess("drv-1", "plc-a", T0.AddSeconds(6));
|
||||
clock.Utc = T0.AddSeconds(10);
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
|
||||
(await _readCtx.DriverInstanceResilienceStatuses.CountAsync()).ShouldBe(1, "one row, updated in place");
|
||||
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
|
||||
row.ConsecutiveFailures.ShouldBe(0);
|
||||
row.LastSampledUtc.ShouldBe(T0.AddSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleHosts_BothPersist_Independently()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordFailure("drv-1", "plc-a", T0);
|
||||
tracker.RecordFailure("drv-1", "plc-a", T0);
|
||||
tracker.RecordFailure("drv-1", "plc-b", T0);
|
||||
|
||||
var host = new ResilienceStatusPublisherHostedService(
|
||||
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
|
||||
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
|
||||
var rows = await _readCtx.DriverInstanceResilienceStatuses
|
||||
.OrderBy(r => r.HostName)
|
||||
.ToListAsync();
|
||||
rows.Count.ShouldBe(2);
|
||||
rows[0].HostName.ShouldBe("plc-a");
|
||||
rows[0].ConsecutiveFailures.ShouldBe(2);
|
||||
rows[1].HostName.ShouldBe("plc-b");
|
||||
rows[1].ConsecutiveFailures.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FootprintCounters_Persist()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
tracker.RecordFootprint("drv-1", "plc-a",
|
||||
baselineBytes: 100_000_000, currentBytes: 150_000_000, T0);
|
||||
|
||||
var host = new ResilienceStatusPublisherHostedService(
|
||||
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
|
||||
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
|
||||
var row = await _readCtx.DriverInstanceResilienceStatuses.SingleAsync();
|
||||
row.BaselineFootprintBytes.ShouldBe(100_000_000);
|
||||
row.CurrentFootprintBytes.ShouldBe(150_000_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickCount_Advances_OnEveryCall()
|
||||
{
|
||||
var tracker = new DriverResilienceStatusTracker();
|
||||
var host = new ResilienceStatusPublisherHostedService(
|
||||
tracker, _factory, NullLogger<ResilienceStatusPublisherHostedService>.Instance);
|
||||
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
await host.PersistOnceAsync(CancellationToken.None);
|
||||
|
||||
host.TickCount.ShouldBe(3);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-004 — the production
|
||||
/// <see cref="OtOpcUaServer.RoleBasedIdentity"/> must surface the LDAP-resolved display
|
||||
/// name through <see cref="IUserIdentity.DisplayName"/>, since
|
||||
/// <c>DriverNodeManager.ResolveCallUser</c> reads the base interface property when stamping
|
||||
/// audit identities on scripted-alarm Acknowledge / Confirm / Shelve calls.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RoleBasedIdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void DisplayName_returns_LDAP_resolved_display_name_when_present()
|
||||
{
|
||||
IUserIdentity identity = new OtOpcUaServer.RoleBasedIdentity(
|
||||
userName: "alice",
|
||||
displayName: "Alice Smith",
|
||||
roles: new[] { "WriteOperate" },
|
||||
ldapGroups: new[] { "ot_operators" });
|
||||
|
||||
identity.DisplayName.ShouldBe("Alice Smith",
|
||||
"DriverNodeManager.ResolveCallUser reads IUserIdentity.DisplayName for audit entries; "
|
||||
+ "RoleBasedIdentity must surface the LDAP-resolved name, not just the username.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_falls_back_to_userName_when_LDAP_display_name_is_null()
|
||||
{
|
||||
IUserIdentity identity = new OtOpcUaServer.RoleBasedIdentity(
|
||||
userName: "alice",
|
||||
displayName: null,
|
||||
roles: [],
|
||||
ldapGroups: []);
|
||||
|
||||
identity.DisplayName.ShouldBe("alice",
|
||||
"absent an LDAP display name, audit entries should still carry the username.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCallUser_yields_LDAP_resolved_display_name()
|
||||
{
|
||||
IUserIdentity identity = new OtOpcUaServer.RoleBasedIdentity(
|
||||
userName: "alice",
|
||||
displayName: "Alice Smith",
|
||||
roles: [],
|
||||
ldapGroups: []);
|
||||
|
||||
DriverNodeManager.ResolveCallUser(identity).ShouldBe("Alice Smith");
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Stability;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScheduledRecycleHostedServiceTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private sealed class FakeClock : TimeProvider
|
||||
{
|
||||
public DateTime Utc { get; set; } = T0;
|
||||
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private sealed class FakeSupervisor : IDriverSupervisor
|
||||
{
|
||||
public string DriverInstanceId => "tier-c-fake";
|
||||
public int RecycleCount { get; private set; }
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
RecycleCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingSupervisor : IDriverSupervisor
|
||||
{
|
||||
public string DriverInstanceId => "tier-c-throws";
|
||||
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
|
||||
=> throw new InvalidOperationException("supervisor unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickOnce_BeforeInterval_DoesNotFire()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var supervisor = new FakeSupervisor();
|
||||
var scheduler = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
host.AddScheduler(scheduler);
|
||||
|
||||
clock.Utc = T0.AddMinutes(1);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
supervisor.RecycleCount.ShouldBe(0);
|
||||
host.TickCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickOnce_AfterInterval_Fires()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var supervisor = new FakeSupervisor();
|
||||
var scheduler = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, supervisor,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
host.AddScheduler(scheduler);
|
||||
|
||||
clock.Utc = T0.AddMinutes(6);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
supervisor.RecycleCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickOnce_MultipleTicks_AccumulateCount()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
host.TickCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddScheduler_AfterStart_Throws()
|
||||
{
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await host.StartAsync(cts.Token); // flips _started true even with cancelled token
|
||||
await host.StopAsync(CancellationToken.None);
|
||||
|
||||
var scheduler = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, new FakeSupervisor(),
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => host.AddScheduler(scheduler));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneSchedulerThrowing_DoesNotStopOthers()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var good = new FakeSupervisor();
|
||||
var bad = new ThrowingSupervisor();
|
||||
|
||||
var goodSch = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, good,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
var badSch = new ScheduledRecycleScheduler(
|
||||
DriverTier.C, TimeSpan.FromMinutes(5), T0, bad,
|
||||
NullLogger<ScheduledRecycleScheduler>.Instance);
|
||||
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
host.AddScheduler(badSch);
|
||||
host.AddScheduler(goodSch);
|
||||
|
||||
clock.Utc = T0.AddMinutes(6);
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
good.RecycleCount.ShouldBe(1, "a faulting scheduler must not poison its neighbours");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchedulerCount_MatchesAdded()
|
||||
{
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance);
|
||||
var sup = new FakeSupervisor();
|
||||
host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(5), DateTime.UtcNow, sup, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
host.AddScheduler(new ScheduledRecycleScheduler(DriverTier.C, TimeSpan.FromMinutes(10), DateTime.UtcNow, sup, NullLogger<ScheduledRecycleScheduler>.Instance));
|
||||
|
||||
host.SchedulerCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyScheduler_List_TicksCleanly()
|
||||
{
|
||||
var clock = new FakeClock();
|
||||
var host = new ScheduledRecycleHostedService(NullLogger<ScheduledRecycleHostedService>.Instance, clock);
|
||||
|
||||
// No registered schedulers — tick is a no-op + counter still advances.
|
||||
await host.TickOnceAsync(CancellationToken.None);
|
||||
|
||||
host.TickCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ScopePathIndexBuilder"/> — the ADR-001 Task B builder that
|
||||
/// produces the full-path <see cref="NodeScope"/> index consumed by
|
||||
/// <see cref="NodeScopeResolver"/> in its indexed mode.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScopePathIndexBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_emits_full_hierarchy_for_well_formed_content()
|
||||
{
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns-eq", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
equipment: [Equip("eq1", "line1")],
|
||||
tags: [TagRow("tag1", "eq1", tagConfig: "Eq1/Speed")]));
|
||||
|
||||
index.Count.ShouldBe(1);
|
||||
var scope = index["Eq1/Speed"];
|
||||
scope.ClusterId.ShouldBe("c1");
|
||||
scope.NamespaceId.ShouldBe("ns-eq");
|
||||
scope.UnsAreaId.ShouldBe("area1");
|
||||
scope.UnsLineId.ShouldBe("line1");
|
||||
scope.EquipmentId.ShouldBe("eq1");
|
||||
scope.TagId.ShouldBe("Eq1/Speed");
|
||||
scope.Kind.ShouldBe(NodeHierarchyKind.Equipment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_skips_tags_with_null_EquipmentId()
|
||||
{
|
||||
// SystemPlatform-namespace tags (decision #110) — the cluster-only resolver
|
||||
// fallback handles them; no index entry needed.
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns-sp", Content(
|
||||
tags: [TagRow("t", equipmentId: null, tagConfig: "Galaxy.Object.Attr")]));
|
||||
|
||||
index.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_skips_tags_with_broken_Equipment_FK()
|
||||
{
|
||||
// Tag references a missing Equipment row. sp_ValidateDraft should have caught this
|
||||
// at publish; builder skips rather than crashes so startup stays bootable.
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
tags: [TagRow("t", "missing-eq", "missing/Speed")]));
|
||||
|
||||
index.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_skips_equipment_with_broken_line_FK()
|
||||
{
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [], // no lines — equipment's UnsLineId misses
|
||||
equipment: [Equip("eq1", "missing")],
|
||||
tags: [TagRow("t", "eq1", "E/S")]));
|
||||
|
||||
index.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_throws_on_duplicate_TagConfig()
|
||||
{
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
equipment: [Equip("eq1", "line1")],
|
||||
tags:
|
||||
[
|
||||
TagRow("t1", "eq1", "E/DUP"),
|
||||
TagRow("t2", "eq1", "E/DUP"),
|
||||
])));
|
||||
|
||||
ex.Message.ShouldContain("Duplicate");
|
||||
ex.Message.ShouldContain("E/DUP");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolver_with_index_returns_full_path_scope()
|
||||
{
|
||||
var index = ScopePathIndexBuilder.Build("c1", "ns", Content(
|
||||
areas: [Area("area1")],
|
||||
lines: [Line("line1", "area1")],
|
||||
equipment: [Equip("eq1", "line1")],
|
||||
tags: [TagRow("t", "eq1", "E/Speed")]));
|
||||
var resolver = new NodeScopeResolver("c1", index);
|
||||
|
||||
var resolved = resolver.Resolve("E/Speed");
|
||||
resolved.UnsAreaId.ShouldBe("area1");
|
||||
resolved.UnsLineId.ShouldBe("line1");
|
||||
resolved.EquipmentId.ShouldBe("eq1");
|
||||
|
||||
// Un-indexed ref falls through to cluster-only scope — pre-ADR-001 behaviour preserved.
|
||||
var fallback = resolver.Resolve("Galaxy.Object.Attr");
|
||||
fallback.ClusterId.ShouldBe("c1");
|
||||
fallback.TagId.ShouldBe("Galaxy.Object.Attr");
|
||||
fallback.UnsAreaId.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- fixture helpers ---------------------------------------------------
|
||||
|
||||
private static EquipmentNamespaceContent Content(
|
||||
IReadOnlyList<UnsArea>? areas = null,
|
||||
IReadOnlyList<UnsLine>? lines = null,
|
||||
IReadOnlyList<Equipment>? equipment = null,
|
||||
IReadOnlyList<Tag>? tags = null) =>
|
||||
new(areas ?? [], lines ?? [], equipment ?? [], tags ?? []);
|
||||
|
||||
private static UnsArea Area(string id) => new()
|
||||
{
|
||||
UnsAreaId = id, ClusterId = "c1", Name = $"Area {id}",
|
||||
};
|
||||
|
||||
private static UnsLine Line(string id, string areaId) => new()
|
||||
{
|
||||
UnsLineId = id, UnsAreaId = areaId, Name = $"Line {id}",
|
||||
};
|
||||
|
||||
private static Equipment Equip(string id, string lineId) => new()
|
||||
{
|
||||
EquipmentId = id, UnsLineId = lineId, DriverInstanceId = "drv",
|
||||
Name = $"Eq {id}", MachineCode = $"M{id}", ZTag = id,
|
||||
};
|
||||
|
||||
private static Tag TagRow(string id, string? equipmentId, string tagConfig) => new()
|
||||
{
|
||||
TagId = id, EquipmentId = equipmentId,
|
||||
DriverInstanceId = "drv",
|
||||
Name = id, DataType = "Int32",
|
||||
AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = tagConfig,
|
||||
};
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration-style tests for the Phase 6.1 Stream D consumption hook — they don't touch
|
||||
/// SQL Server (the real SealedBootstrap does, via sp_GetCurrentGenerationForCluster), but
|
||||
/// they exercise ResilientConfigReader + GenerationSealedCache + StaleConfigFlag end-to-end
|
||||
/// by simulating central-DB outcomes through a direct ReadAsync call.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class SealedBootstrapIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-bootstrap-{Guid.NewGuid():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_root)) return;
|
||||
foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
|
||||
File.SetAttributes(f, FileAttributes.Normal);
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbSuccess_SealsSnapshot_And_FlagFresh()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10));
|
||||
|
||||
// Simulate the SealedBootstrap fresh-path: central DB returns generation id 42; the
|
||||
// bootstrap seals it + ResilientConfigReader marks the flag fresh.
|
||||
var result = await reader.ReadAsync(
|
||||
"c-a",
|
||||
centralFetch: async _ =>
|
||||
{
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "c-a",
|
||||
GenerationId = 42,
|
||||
CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"gen\":42}",
|
||||
}, CancellationToken.None);
|
||||
return (long?)42;
|
||||
},
|
||||
fromSnapshot: snap => (long?)snap.GenerationId,
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(42);
|
||||
flag.IsStale.ShouldBeFalse();
|
||||
cache.TryGetCurrentGenerationId("c-a").ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CentralDbFails_FallsBackToSealedSnapshot_FlagStale()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
// Seed a prior sealed snapshot (simulating a previous successful boot).
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "c-a", GenerationId = 37, CachedAt = DateTime.UtcNow,
|
||||
PayloadJson = "{\"gen\":37}",
|
||||
});
|
||||
|
||||
// Now simulate central DB down → fallback.
|
||||
var result = await reader.ReadAsync(
|
||||
"c-a",
|
||||
centralFetch: _ => throw new InvalidOperationException("SQL dead"),
|
||||
fromSnapshot: snap => (long?)snap.GenerationId,
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBe(37);
|
||||
flag.IsStale.ShouldBeTrue("cache fallback flips the /healthz flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoSnapshot_AndCentralDown_Throws_ClearError()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
|
||||
{
|
||||
await reader.ReadAsync<long?>(
|
||||
"c-a",
|
||||
centralFetch: _ => throw new InvalidOperationException("SQL dead"),
|
||||
fromSnapshot: snap => (long?)snap.GenerationId,
|
||||
CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuccessfulBootstrap_AfterFailure_ClearsStaleFlag()
|
||||
{
|
||||
var cache = new GenerationSealedCache(_root);
|
||||
var flag = new StaleConfigFlag();
|
||||
var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
|
||||
timeout: TimeSpan.FromSeconds(10), retryCount: 0);
|
||||
|
||||
await cache.SealAsync(new GenerationSnapshot
|
||||
{
|
||||
ClusterId = "c-a", GenerationId = 1, CachedAt = DateTime.UtcNow, PayloadJson = "{}",
|
||||
});
|
||||
|
||||
// Fallback serves snapshot → flag goes stale.
|
||||
await reader.ReadAsync("c-a",
|
||||
centralFetch: _ => throw new InvalidOperationException("dead"),
|
||||
fromSnapshot: s => (long?)s.GenerationId,
|
||||
CancellationToken.None);
|
||||
flag.IsStale.ShouldBeTrue();
|
||||
|
||||
// Subsequent successful bootstrap clears it.
|
||||
await reader.ReadAsync("c-a",
|
||||
centralFetch: _ => ValueTask.FromResult((long?)5),
|
||||
fromSnapshot: s => (long?)s.GenerationId,
|
||||
CancellationToken.None);
|
||||
flag.IsStale.ShouldBeFalse("next successful DB round-trip clears the flag");
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression for Server-014 — <see cref="SealedBootstrap"/> exists in the source tree and
|
||||
/// is referenced by <c>docs/v2/v2-release-readiness.md</c> as the closed release blocker for
|
||||
/// generation-sealed config plumbing, but it was never registered in the production DI
|
||||
/// container. The release blocker remained de-facto open. This test asserts the DI
|
||||
/// registrations (which <c>Program.cs</c> performs at startup) actually compose: every
|
||||
/// dependency <see cref="SealedBootstrap"/> needs — <see cref="GenerationSealedCache"/>,
|
||||
/// <see cref="ResilientConfigReader"/>, <see cref="StaleConfigFlag"/> — must be resolvable
|
||||
/// so the production wire-up doesn't fail with a missing-service exception at startup.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SealedBootstrapWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public void SealedBootstrap_and_its_dependencies_are_registered_in_DI()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), $"otopcua-sealed-bootstrap-wiring-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
// Mirror Program.cs's registrations of NodeOptions + the SealedBootstrap chain.
|
||||
var services = new ServiceCollection();
|
||||
ZB.MOM.WW.OtOpcUa.Server.ServerWiring.AddSealedBootstrap(services, new NodeOptions
|
||||
{
|
||||
NodeId = "test-node",
|
||||
ClusterId = "test-cluster",
|
||||
ConfigDbConnectionString = "Server=fake;Database=fake;Integrated Security=true;",
|
||||
LocalCachePath = tempRoot,
|
||||
});
|
||||
services.AddSingleton(NullLoggerFactory.Instance);
|
||||
services.AddLogging();
|
||||
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
sp.GetRequiredService<GenerationSealedCache>().ShouldNotBeNull();
|
||||
sp.GetRequiredService<ResilientConfigReader>().ShouldNotBeNull();
|
||||
sp.GetRequiredService<StaleConfigFlag>().ShouldNotBeNull();
|
||||
sp.GetRequiredService<SealedBootstrap>().ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (Directory.Exists(tempRoot)) Directory.Delete(tempRoot, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SecurityConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DenyAllAuthenticator_rejects_every_credential()
|
||||
{
|
||||
var auth = new DenyAllUserAuthenticator();
|
||||
var r = await auth.AuthenticateAsync("admin", "admin", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("not supported");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_blank_credentials_without_hitting_server()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = true, AllowInsecureLdap = true };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var empty = await auth.AuthenticateAsync("", "", CancellationToken.None);
|
||||
empty.Success.ShouldBeFalse();
|
||||
empty.Error.ShouldContain("Credentials");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_when_disabled()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = false };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LdapAuthenticator_rejects_plaintext_when_both_TLS_and_insecure_are_disabled()
|
||||
{
|
||||
var options = new LdapOptions { Enabled = true, UseTls = false, AllowInsecureLdap = false };
|
||||
var auth = new LdapUserAuthenticator(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
var r = await auth.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
r.Success.ShouldBeFalse();
|
||||
r.Error.ShouldContain("Insecure");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("hello", "hello")]
|
||||
[InlineData("hi(there)", "hi\\28there\\29")]
|
||||
[InlineData("name*", "name\\2a")]
|
||||
[InlineData("a\\b", "a\\5cb")]
|
||||
public void LdapFilter_escapes_reserved_characters(string input, string expected)
|
||||
{
|
||||
LdapUserAuthenticator.EscapeLdapFilter(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cn=alice,ou=Engineering,dc=example,dc=com", "Engineering")]
|
||||
[InlineData("cn=bob,dc=example,dc=com", null)]
|
||||
[InlineData("cn=carol,ou=Ops,dc=example,dc=com", "Ops")]
|
||||
public void ExtractOuSegment_pulls_primary_group_from_DN(string dn, string? expected)
|
||||
{
|
||||
LdapUserAuthenticator.ExtractOuSegment(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cn=Operators,ou=Groups,dc=example", "Operators")]
|
||||
[InlineData("cn=LoneValue", "LoneValue")]
|
||||
[InlineData("plain-no-equals", "plain-no-equals")]
|
||||
public void ExtractFirstRdnValue_returns_first_rdn(string dn, string expected)
|
||||
{
|
||||
LdapUserAuthenticator.ExtractFirstRdnValue(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpcUaServerOptions_default_is_anonymous_only()
|
||||
{
|
||||
var opts = new OpcUaServerOptions();
|
||||
opts.SecurityProfile.ShouldBe(OpcUaSecurityProfile.None);
|
||||
opts.Ldap.Enabled.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
using ConfigRedundancyMode = ZB.MOM.WW.OtOpcUa.Configuration.Enums.RedundancyMode;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for <see cref="ServerRedundancyNodeWriter"/>. Uses a <see cref="DispatchProxy"/>
|
||||
/// stand-in for <see cref="IServerInternal"/> — the writer only needs <c>ServerObject</c> +
|
||||
/// <c>DefaultSystemContext</c>, so we stub just those and let every other member return
|
||||
/// null (the writer never touches anything else).
|
||||
/// </summary>
|
||||
public sealed class ServerRedundancyNodeWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplyServiceLevel_sets_node_value_and_dedupes_unchanged()
|
||||
{
|
||||
var env = BuildEnv();
|
||||
|
||||
env.Writer.ApplyServiceLevel(200);
|
||||
env.ServerObject.ServiceLevel.Value.ShouldBe((byte)200);
|
||||
|
||||
var timestampAfterFirst = env.ServerObject.ServiceLevel.Timestamp;
|
||||
|
||||
// Same value — writer should early-out without touching Timestamp.
|
||||
Thread.Sleep(5);
|
||||
env.Writer.ApplyServiceLevel(200);
|
||||
env.ServerObject.ServiceLevel.Timestamp.ShouldBe(timestampAfterFirst);
|
||||
|
||||
env.Writer.ApplyServiceLevel(150);
|
||||
env.ServerObject.ServiceLevel.Value.ShouldBe((byte)150);
|
||||
env.ServerObject.ServiceLevel.Timestamp.ShouldBeGreaterThan(timestampAfterFirst);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyRedundancySupport_maps_config_enum()
|
||||
{
|
||||
var env = BuildEnv();
|
||||
|
||||
env.Writer.ApplyRedundancySupport(ConfigRedundancyMode.Warm);
|
||||
env.ServerObject.ServerRedundancy.RedundancySupport.Value.ShouldBe(RedundancySupport.Warm);
|
||||
|
||||
env.Writer.ApplyRedundancySupport(ConfigRedundancyMode.Hot);
|
||||
env.ServerObject.ServerRedundancy.RedundancySupport.Value.ShouldBe(RedundancySupport.Hot);
|
||||
|
||||
env.Writer.ApplyRedundancySupport(ConfigRedundancyMode.None);
|
||||
env.ServerObject.ServerRedundancy.RedundancySupport.Value.ShouldBe(RedundancySupport.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyServerUriArray_writes_when_non_transparent_state_present()
|
||||
{
|
||||
var env = BuildEnv(nonTransparent: true);
|
||||
|
||||
env.Writer.ApplyServerUriArray(["urn:self", "urn:peer"]);
|
||||
var ntr = (NonTransparentRedundancyState)env.ServerObject.ServerRedundancy;
|
||||
ntr.ServerUriArray.Value.ShouldBe(new[] { "urn:self", "urn:peer" });
|
||||
|
||||
var ts = ntr.ServerUriArray.Timestamp;
|
||||
Thread.Sleep(5);
|
||||
env.Writer.ApplyServerUriArray(["urn:self", "urn:peer"]); // dedupe
|
||||
ntr.ServerUriArray.Timestamp.ShouldBe(ts);
|
||||
|
||||
env.Writer.ApplyServerUriArray(["urn:self", "urn:peer", "urn:peer2"]);
|
||||
ntr.ServerUriArray.Value.Length.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyServerUriArray_skips_silently_on_base_redundancy_type()
|
||||
{
|
||||
var env = BuildEnv(nonTransparent: false);
|
||||
Should.NotThrow(() => env.Writer.ApplyServerUriArray(["urn:self"]));
|
||||
env.ServerObject.ServerRedundancy.ShouldBeOfType<ServerRedundancyState>();
|
||||
}
|
||||
|
||||
private static Env BuildEnv(bool nonTransparent = false)
|
||||
{
|
||||
var serverObject = new ServerObjectState(parent: null)
|
||||
{
|
||||
ServiceLevel = new PropertyState<byte>(null),
|
||||
};
|
||||
serverObject.ServerRedundancy = nonTransparent
|
||||
? new NonTransparentRedundancyState(serverObject)
|
||||
{
|
||||
RedundancySupport = new PropertyState<RedundancySupport>(null),
|
||||
ServerUriArray = new PropertyState<string[]>(null),
|
||||
}
|
||||
: new ServerRedundancyState(serverObject)
|
||||
{
|
||||
RedundancySupport = new PropertyState<RedundancySupport>(null),
|
||||
};
|
||||
|
||||
var proxy = DispatchProxy.Create<IServerInternal, FakeServerInternalProxy>();
|
||||
var fake = (FakeServerInternalProxy)(object)proxy;
|
||||
fake.ServerObjectValue = serverObject;
|
||||
fake.DefaultSystemContextValue = new ServerSystemContext(proxy);
|
||||
|
||||
var writer = new ServerRedundancyNodeWriter(proxy, NullLogger<ServerRedundancyNodeWriter>.Instance);
|
||||
return new Env(proxy, serverObject, writer);
|
||||
}
|
||||
|
||||
private sealed record Env(
|
||||
IServerInternal Server,
|
||||
ServerObjectState ServerObject,
|
||||
ServerRedundancyNodeWriter Writer);
|
||||
|
||||
public class FakeServerInternalProxy : DispatchProxy
|
||||
{
|
||||
public ServerObjectState? ServerObjectValue;
|
||||
public ISystemContext? DefaultSystemContextValue;
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) =>
|
||||
targetMethod?.Name switch
|
||||
{
|
||||
"get_ServerObject" => ServerObjectValue,
|
||||
"get_DefaultSystemContext" => DefaultSystemContextValue,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ServiceLevelCalculatorTests
|
||||
{
|
||||
// --- Reserved bands (0, 1, 2) ---
|
||||
|
||||
[Fact]
|
||||
public void OperatorMaintenance_Overrides_Everything()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true,
|
||||
operatorMaintenance: true);
|
||||
|
||||
v.ShouldBe((byte)ServiceLevelBand.Maintenance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnhealthySelf_ReturnsNoData()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: false, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)ServiceLevelBand.NoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidTopology_Demotes_BothNodes_To_2()
|
||||
{
|
||||
var primary = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: false);
|
||||
var secondary = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Secondary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: false);
|
||||
|
||||
primary.ShouldBe((byte)ServiceLevelBand.InvalidTopology);
|
||||
secondary.ShouldBe((byte)ServiceLevelBand.InvalidTopology);
|
||||
}
|
||||
|
||||
// --- Operational bands (authoritative) ---
|
||||
|
||||
[Fact]
|
||||
public void Authoritative_Primary_Is_255()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)ServiceLevelBand.AuthoritativePrimary);
|
||||
v.ShouldBe((byte)255);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authoritative_Backup_Is_100()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Secondary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)100);
|
||||
}
|
||||
|
||||
// --- Isolated bands ---
|
||||
|
||||
[Fact]
|
||||
public void IsolatedPrimary_PeerUnreachable_Is_230_RetainsAuthority()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)230);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsolatedBackup_PrimaryUnreachable_Is_80_DoesNotPromote()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Secondary,
|
||||
selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)80, "Backup isolates at 80 — doesn't auto-promote to 255");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HttpOnly_Unreachable_TriggersIsolated()
|
||||
{
|
||||
// Either probe failing marks peer unreachable — UA probe is authoritative but HTTP is
|
||||
// the fast-fail short-circuit; either missing means "not a valid peer right now".
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: false,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)230);
|
||||
}
|
||||
|
||||
// --- Apply-mid bands ---
|
||||
|
||||
[Fact]
|
||||
public void PrimaryMidApply_Is_200()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: true, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackupMidApply_Is_50()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Secondary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: true, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyInProgress_Dominates_PeerUnreachable()
|
||||
{
|
||||
// Per Stream C.4 integration-test expectation: mid-apply + peer down → apply wins (200).
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false,
|
||||
applyInProgress: true, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
// --- Recovering bands ---
|
||||
|
||||
[Fact]
|
||||
public void RecoveringPrimary_Is_180()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Primary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: false, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)180);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveringBackup_Is_30()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Secondary,
|
||||
selfHealthy: true, peerUaHealthy: true, peerHttpHealthy: true,
|
||||
applyInProgress: false, recoveryDwellMet: false, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)30);
|
||||
}
|
||||
|
||||
// --- Standalone node (no peer) ---
|
||||
|
||||
[Fact]
|
||||
public void Standalone_IsAuthoritativePrimary_WhenHealthy()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Standalone,
|
||||
selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false,
|
||||
applyInProgress: false, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)255, "Standalone has no peer — treat healthy as authoritative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Standalone_MidApply_Is_200()
|
||||
{
|
||||
var v = ServiceLevelCalculator.Compute(
|
||||
RedundancyRole.Standalone,
|
||||
selfHealthy: true, peerUaHealthy: false, peerHttpHealthy: false,
|
||||
applyInProgress: true, recoveryDwellMet: true, topologyValid: true);
|
||||
|
||||
v.ShouldBe((byte)200);
|
||||
}
|
||||
|
||||
// --- Classify round-trip ---
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0, ServiceLevelBand.Maintenance)]
|
||||
[InlineData((byte)1, ServiceLevelBand.NoData)]
|
||||
[InlineData((byte)2, ServiceLevelBand.InvalidTopology)]
|
||||
[InlineData((byte)30, ServiceLevelBand.RecoveringBackup)]
|
||||
[InlineData((byte)50, ServiceLevelBand.BackupMidApply)]
|
||||
[InlineData((byte)80, ServiceLevelBand.IsolatedBackup)]
|
||||
[InlineData((byte)100, ServiceLevelBand.AuthoritativeBackup)]
|
||||
[InlineData((byte)180, ServiceLevelBand.RecoveringPrimary)]
|
||||
[InlineData((byte)200, ServiceLevelBand.PrimaryMidApply)]
|
||||
[InlineData((byte)230, ServiceLevelBand.IsolatedPrimary)]
|
||||
[InlineData((byte)255, ServiceLevelBand.AuthoritativePrimary)]
|
||||
[InlineData((byte)123, ServiceLevelBand.Unknown)]
|
||||
public void Classify_RoundTrips_EveryBand(byte value, ServiceLevelBand expected)
|
||||
{
|
||||
ServiceLevelCalculator.Classify(value).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #124 — Phase 6.2 multi-user interop matrix. Drives the live GLAuth dev directory
|
||||
/// (5 distinct group memberships, plus a multi-group admin) end-to-end through:
|
||||
/// <c>LdapUserAuthenticator</c> bind → resolved LDAP group list →
|
||||
/// <see cref="AuthorizationGate.IsAllowed"/> against a seeded
|
||||
/// <see cref="TriePermissionEvaluator"/> → expected allow/deny verdict.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the closest a code pass can get to the manual "3-user interop matrix" Phase 6.2
|
||||
/// deliverable. The remaining wire-level layer (real OPC UA client, encrypted UserName
|
||||
/// token through the endpoint policy) needs a security-profile knob that's tracked
|
||||
/// separately and stays a manual cross-client smoke (#119 / #124 manual scope).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Closes the production gap surfaced while planning this test: <c>RoleBasedIdentity</c>
|
||||
/// did not implement <see cref="ILdapGroupsBearer"/>, so <see cref="AuthorizationGate"/>
|
||||
/// lax-mode-allowed every request because it never received resolved LDAP groups. After
|
||||
/// this PR <see cref="UserAuthResult"/> carries <c>Groups</c> alongside <c>Roles</c> and
|
||||
/// <c>RoleBasedIdentity</c> exposes them via the bearer interface.
|
||||
/// </para>
|
||||
/// <para>Skipped when GLAuth at <c>localhost:3893</c> is unreachable so the suite stays
|
||||
/// portable.</para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "LiveLdap")]
|
||||
public sealed class ThreeUserInteropMatrixTests
|
||||
{
|
||||
private const string GlauthHost = "localhost";
|
||||
private const int GlauthPort = 3893;
|
||||
private const string ClusterId = "c1";
|
||||
|
||||
private static bool GlauthReachable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(GlauthHost, GlauthPort);
|
||||
return task.Wait(TimeSpan.FromSeconds(1)) && client.Connected;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static LdapOptions GlauthOptions() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = GlauthHost,
|
||||
Port = GlauthPort,
|
||||
UseTls = false,
|
||||
AllowInsecureLdap = true,
|
||||
SearchBase = "dc=lmxopcua,dc=local",
|
||||
ServiceAccountDn = "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
ServiceAccountPassword = "serviceaccount123",
|
||||
DisplayNameAttribute = "cn",
|
||||
GroupAttribute = "memberOf",
|
||||
UserNameAttribute = "cn",
|
||||
// Identity translation — GLAuth group RDN values are the same strings as the
|
||||
// OPC UA roles we map to, so the GroupToRole table is straightforward.
|
||||
GroupToRole = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ReadOnly"] = "ReadOnly",
|
||||
["WriteOperate"] = WriteAuthzPolicy.RoleWriteOperate,
|
||||
["WriteTune"] = WriteAuthzPolicy.RoleWriteTune,
|
||||
["WriteConfigure"] = WriteAuthzPolicy.RoleWriteConfigure,
|
||||
["AlarmAck"] = "AlarmAck",
|
||||
},
|
||||
};
|
||||
|
||||
private static LdapUserAuthenticator NewAuthenticator() =>
|
||||
new(GlauthOptions(), NullLogger<LdapUserAuthenticator>.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// Production-shaped ACL ruleset — one row per LDAP group, granted at Cluster scope so
|
||||
/// it covers any node the matrix probes. Each group gets exactly the flags it needs;
|
||||
/// the matrix asserts the flag-by-flag isolation the evaluator must preserve.
|
||||
/// </summary>
|
||||
private static NodeAcl[] AclMatrix() =>
|
||||
[
|
||||
Row("ReadOnly", NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.HistoryRead),
|
||||
Row("WriteOperate", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate),
|
||||
Row("WriteTune", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune),
|
||||
Row("WriteConfigure", NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteConfigure),
|
||||
Row("AlarmAck", NodePermissions.Browse | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve),
|
||||
];
|
||||
|
||||
private static NodeAcl Row(string group, NodePermissions flags) => new()
|
||||
{
|
||||
NodeAclRowId = Guid.NewGuid(),
|
||||
NodeAclId = Guid.NewGuid().ToString(),
|
||||
GenerationId = 1,
|
||||
ClusterId = ClusterId,
|
||||
LdapGroup = group,
|
||||
ScopeKind = NodeAclScopeKind.Cluster,
|
||||
ScopeId = null,
|
||||
PermissionFlags = flags,
|
||||
};
|
||||
|
||||
private static NodeScope Scope() => new()
|
||||
{
|
||||
ClusterId = ClusterId,
|
||||
NamespaceId = "ns",
|
||||
UnsAreaId = "area",
|
||||
UnsLineId = "line",
|
||||
EquipmentId = "eq",
|
||||
TagId = "tag1",
|
||||
Kind = NodeHierarchyKind.Equipment,
|
||||
};
|
||||
|
||||
private static AuthorizationGate MakeStrictGate()
|
||||
{
|
||||
var cache = new PermissionTrieCache();
|
||||
cache.Install(PermissionTrieBuilder.Build(ClusterId, 1, AclMatrix()));
|
||||
return new AuthorizationGate(new TriePermissionEvaluator(cache), strictMode: true, trieCache: cache);
|
||||
}
|
||||
|
||||
private sealed class LdapBoundIdentity : UserIdentity, ILdapGroupsBearer
|
||||
{
|
||||
public LdapBoundIdentity(string userName, IReadOnlyList<string> groups)
|
||||
{
|
||||
DisplayName = userName;
|
||||
LdapGroups = groups;
|
||||
}
|
||||
public new string DisplayName { get; }
|
||||
public IReadOnlyList<string> LdapGroups { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end: bind via LDAP, observe the resolved groups, drive every
|
||||
/// <see cref="OpcUaOperation"/> in the relevant subset through the strict-mode gate, and
|
||||
/// assert the expected verdict. One InlineData row per (user, operation) pair so failures
|
||||
/// report the precise cell that broke.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
// readonly — read-side only
|
||||
[InlineData("readonly", "readonly123", OpcUaOperation.Browse, true)]
|
||||
[InlineData("readonly", "readonly123", OpcUaOperation.Read, true)]
|
||||
[InlineData("readonly", "readonly123", OpcUaOperation.HistoryRead, true)]
|
||||
[InlineData("readonly", "readonly123", OpcUaOperation.WriteOperate, false)]
|
||||
[InlineData("readonly", "readonly123", OpcUaOperation.WriteTune, false)]
|
||||
[InlineData("readonly", "readonly123", OpcUaOperation.WriteConfigure, false)]
|
||||
[InlineData("readonly", "readonly123", OpcUaOperation.AlarmAcknowledge, false)]
|
||||
// writeop — Operate writes only, no escalation to Tune/Configure/Alarm
|
||||
[InlineData("writeop", "writeop123", OpcUaOperation.Read, true)]
|
||||
[InlineData("writeop", "writeop123", OpcUaOperation.WriteOperate, true)]
|
||||
[InlineData("writeop", "writeop123", OpcUaOperation.WriteTune, false)]
|
||||
[InlineData("writeop", "writeop123", OpcUaOperation.WriteConfigure, false)]
|
||||
[InlineData("writeop", "writeop123", OpcUaOperation.AlarmAcknowledge, false)]
|
||||
// writetune — Tune writes only
|
||||
[InlineData("writetune", "writetune123", OpcUaOperation.Read, true)]
|
||||
[InlineData("writetune", "writetune123", OpcUaOperation.WriteOperate, false)]
|
||||
[InlineData("writetune", "writetune123", OpcUaOperation.WriteTune, true)]
|
||||
[InlineData("writetune", "writetune123", OpcUaOperation.WriteConfigure, false)]
|
||||
// writeconfig — Configure writes only
|
||||
[InlineData("writeconfig", "writeconfig123", OpcUaOperation.Read, true)]
|
||||
[InlineData("writeconfig", "writeconfig123", OpcUaOperation.WriteOperate, false)]
|
||||
[InlineData("writeconfig", "writeconfig123", OpcUaOperation.WriteTune, false)]
|
||||
[InlineData("writeconfig", "writeconfig123", OpcUaOperation.WriteConfigure, true)]
|
||||
// alarmack — alarm-only; deliberately has no Read grant. Verifies flag isolation.
|
||||
[InlineData("alarmack", "alarmack123", OpcUaOperation.Browse, true)]
|
||||
[InlineData("alarmack", "alarmack123", OpcUaOperation.Read, false)]
|
||||
[InlineData("alarmack", "alarmack123", OpcUaOperation.WriteOperate, false)]
|
||||
[InlineData("alarmack", "alarmack123", OpcUaOperation.AlarmAcknowledge, true)]
|
||||
[InlineData("alarmack", "alarmack123", OpcUaOperation.AlarmConfirm, true)]
|
||||
[InlineData("alarmack", "alarmack123", OpcUaOperation.AlarmShelve, true)]
|
||||
// admin — member of every group; OR-ing across groups means everything is allowed.
|
||||
[InlineData("admin", "admin123", OpcUaOperation.Read, true)]
|
||||
[InlineData("admin", "admin123", OpcUaOperation.WriteOperate, true)]
|
||||
[InlineData("admin", "admin123", OpcUaOperation.WriteTune, true)]
|
||||
[InlineData("admin", "admin123", OpcUaOperation.WriteConfigure, true)]
|
||||
[InlineData("admin", "admin123", OpcUaOperation.AlarmAcknowledge, true)]
|
||||
public async Task Matrix(string username, string password, OpcUaOperation op, bool expectAllow)
|
||||
{
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893 — start the dev directory to run this test.");
|
||||
|
||||
var auth = await NewAuthenticator().AuthenticateAsync(username, password, TestContext.Current.CancellationToken);
|
||||
auth.Success.ShouldBeTrue($"LDAP bind for {username} failed: {auth.Error}");
|
||||
auth.Groups.ShouldNotBeEmpty($"{username} resolved zero LDAP groups — the bind succeeded but the directory query returned nothing");
|
||||
|
||||
var identity = new LdapBoundIdentity(username, auth.Groups);
|
||||
var gate = MakeStrictGate();
|
||||
|
||||
var allowed = gate.IsAllowed(identity, op, Scope());
|
||||
|
||||
allowed.ShouldBe(expectAllow,
|
||||
$"user={username} op={op} groups=[{string.Join(",", auth.Groups)}] expected={expectAllow}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_Resolves_All_Five_Groups_From_LDAP()
|
||||
{
|
||||
// Sanity check separate from the matrix: the admin user must surface every group it
|
||||
// belongs to via the new UserAuthResult.Groups channel — the matrix above relies on
|
||||
// exactly this. If the directory query missed a group, the per-op allow rows for admin
|
||||
// could pass for the wrong reason (e.g. through lax-mode fallback), so this test
|
||||
// pins the resolution explicitly in strict mode.
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893.");
|
||||
|
||||
// Under parallel full-solution test load, GLAuth on localhost can be slow to
|
||||
// respond; use a generous per-call timeout independent of xUnit's test runner
|
||||
// deadline so we don't race against the runner's own CancellationToken, and
|
||||
// retry once on timeout to absorb transient latency spikes.
|
||||
const int LdapTimeoutSeconds = 15;
|
||||
UserAuthResult? auth = null;
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
TestContext.Current.CancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(LdapTimeoutSeconds));
|
||||
try
|
||||
{
|
||||
auth = await NewAuthenticator().AuthenticateAsync("admin", "admin123", cts.Token);
|
||||
break; // success — no retry needed
|
||||
}
|
||||
catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (attempt == 1) throw; // second attempt also timed out — let it fail
|
||||
// First attempt timed out under load; retry once with a fresh token.
|
||||
}
|
||||
}
|
||||
|
||||
auth.ShouldNotBeNull();
|
||||
auth!.Success.ShouldBeTrue();
|
||||
auth.Groups.ShouldContain("ReadOnly");
|
||||
auth.Groups.ShouldContain("WriteOperate");
|
||||
auth.Groups.ShouldContain("WriteTune");
|
||||
auth.Groups.ShouldContain("WriteConfigure");
|
||||
auth.Groups.ShouldContain("AlarmAck");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_Bind_Returns_Empty_Groups_And_Empty_Roles()
|
||||
{
|
||||
// Failure path must not surface any group claims — the gate would be misled into
|
||||
// resolving permissions for a user who never authenticated.
|
||||
if (!GlauthReachable()) Assert.Skip("GLAuth unreachable at localhost:3893.");
|
||||
|
||||
var auth = await NewAuthenticator().AuthenticateAsync("readonly", "wrong-password", TestContext.Current.CancellationToken);
|
||||
|
||||
auth.Success.ShouldBeFalse();
|
||||
auth.Groups.ShouldBeEmpty();
|
||||
auth.Roles.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteAuthzPolicyTests
|
||||
{
|
||||
// --- FreeAccess and ViewOnly special-cases ---
|
||||
|
||||
[Fact]
|
||||
public void FreeAccess_allows_write_even_for_empty_role_set()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, []).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeAccess_allows_write_for_arbitrary_roles()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, ["SomeOtherRole"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewOnly_denies_write_even_with_every_role()
|
||||
{
|
||||
var allRoles = new[] { "WriteOperate", "WriteTune", "WriteConfigure", "AlarmAck" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.ViewOnly, allRoles).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Operate tier ---
|
||||
|
||||
[Fact]
|
||||
public void Operate_requires_WriteOperate_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WriteOperate"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_role_match_is_case_insensitive()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["writeoperate"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WRITEOPERATE"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_denies_empty_role_set()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_denies_wrong_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["ReadOnly"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecuredWrite_maps_to_same_WriteOperate_requirement_as_Operate()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteOperate"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteTune"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Tune tier ---
|
||||
|
||||
[Fact]
|
||||
public void Tune_requires_WriteTune_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteTune"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tune_denies_WriteOperate_only_session()
|
||||
{
|
||||
// Important: role roles do NOT cascade — a session with WriteOperate can't write a Tune
|
||||
// attribute. Operators escalate by adding WriteTune to the session's roles, not by a
|
||||
// hierarchy the policy infers on its own.
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteOperate"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Configure tier ---
|
||||
|
||||
[Fact]
|
||||
public void Configure_requires_WriteConfigure_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, ["WriteConfigure"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifiedWrite_maps_to_same_WriteConfigure_requirement_as_Configure()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteConfigure"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteOperate"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Multi-role sessions ---
|
||||
|
||||
[Fact]
|
||||
public void Session_with_multiple_roles_is_allowed_when_any_matches()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "WriteTune", "AlarmAck" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, roles).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_with_only_unrelated_roles_is_denied()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "AlarmAck", "SomeCustomRole" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, roles).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Mapping table ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecurityClassification.Operate, WriteAuthzPolicy.RoleWriteOperate)]
|
||||
[InlineData(SecurityClassification.SecuredWrite, WriteAuthzPolicy.RoleWriteOperate)]
|
||||
[InlineData(SecurityClassification.Tune, WriteAuthzPolicy.RoleWriteTune)]
|
||||
[InlineData(SecurityClassification.VerifiedWrite, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||
[InlineData(SecurityClassification.Configure, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||
public void RequiredRole_returns_expected_role_for_classification(SecurityClassification c, string expected)
|
||||
{
|
||||
WriteAuthzPolicy.RequiredRole(c).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecurityClassification.FreeAccess)]
|
||||
[InlineData(SecurityClassification.ViewOnly)]
|
||||
public void RequiredRole_returns_null_for_special_classifications(SecurityClassification c)
|
||||
{
|
||||
WriteAuthzPolicy.RequiredRole(c).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Server.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
|
||||
<!-- Pinned to 1.5.374.126 to match the legacy Server project's Opc.Ua.Server version.
|
||||
Mixing Opc.Ua.Core versions between the project under test and the test project causes
|
||||
CS7069 'ServiceResult is defined in Opc.Ua.Core but could not be found'. Deleted in
|
||||
Task 56 alongside the Server project. -->
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" VersionOverride="1.5.374.126"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Server\ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user