chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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.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>();
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
@@ -0,0 +1,34 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<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" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.51.0"/>
|
||||
</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>
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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 = "localhost,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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
|
||||
{
|
||||
private const string Header =
|
||||
"# OtOpcUaCsv v1\n" +
|
||||
"ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName";
|
||||
|
||||
[Fact]
|
||||
public void EmptyFile_Throws()
|
||||
{
|
||||
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingVersionMarker_Throws()
|
||||
{
|
||||
var csv = "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,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,EquipmentId,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,mc,sap,eq1,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,eq1,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,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
|
||||
"z1,z1,mc,sap,eq,uu,Name,area,line";
|
||||
|
||||
Should.Throw<InvalidCsvFormatException>(() => EquipmentCsvImporter.Parse(csv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidSingleRow_RoundTrips()
|
||||
{
|
||||
var csv = Header + "\nz-001,MC-1,SAP-1,eq-001,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,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
|
||||
"z-1,MC,SAP,eq,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,eq,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,eq1,u1,N1,A,L1" +
|
||||
"\nz-1,MC2,SAP2,eq2,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\",\"eq\",\"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,eq,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,eq1,u1,N1,A,L1" +
|
||||
"\n" +
|
||||
"\nz-2,MC,SAP,eq2,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()
|
||||
{
|
||||
EquipmentCsvImporter.RequiredColumns.ShouldBe(
|
||||
["ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
|
||||
|
||||
EquipmentCsvImporter.OptionalColumns.ShouldBe(
|
||||
["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
|
||||
"YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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.
|
||||
private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new()
|
||||
{
|
||||
ZTag = zTag,
|
||||
MachineCode = "mc",
|
||||
SAPID = $"sap-{zTag}",
|
||||
EquipmentId = "eq-id",
|
||||
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",
|
||||
EquipmentId = "eq-1", 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",
|
||||
EquipmentId = "eq-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 rowB = new EquipmentCsvRow
|
||||
{
|
||||
ZTag = "z-collide", MachineCode = "mc-b", SAPID = "sap-b", // same ZTag, different EquipmentUuid
|
||||
EquipmentId = "eq-b", EquipmentUuid = Guid.NewGuid().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.
|
||||
var equipmentB = await _db.Equipment.AsNoTracking()
|
||||
.Where(e => e.EquipmentId == "eq-b")
|
||||
.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 = "",
|
||||
EquipmentId = "eq-nil", 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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 = "localhost,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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
146
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/TagServiceTests.cs
Normal file
146
tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/TagServiceTests.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<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" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<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" Version="6.1.1"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,322 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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);
|
||||
return new AuthorizationGate(evaluator, strictMode: strict);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
159
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/BrowseGatingTests.cs
Normal file
159
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/BrowseGatingTests.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
156
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs
Normal file
156
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/CallGatingTests.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
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_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 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);
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Linq;
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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);
|
||||
|
||||
_ = 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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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, []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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 = "localhost,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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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();
|
||||
}
|
||||
|
||||
// ---- 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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.");
|
||||
|
||||
var auth = await NewAuthenticator().AuthenticateAsync("admin", "admin123", TestContext.Current.CancellationToken);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<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" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
|
||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.126"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<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