refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,121 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// ConfigurationDatabase-012: creating an API key must generate a random key,
/// persist only its peppered hash, and return the plaintext to the caller exactly
/// once. The plaintext must never reach the stored entity.
/// </summary>
public class ApiKeyCreationTests : TestKit, IDisposable
{
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
private readonly ServiceCollection _services = new();
private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>();
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
public ApiKeyCreationTests()
{
_services.AddScoped(_ => _apiRepo);
_services.AddScoped(_ => _auditService);
_services.AddSingleton<IApiKeyHasher>(new ApiKeyHasher(Pepper));
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
new(new AuthenticatedUser("admin", "Admin User", roles, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
[Fact]
public void CreateApiKey_PersistsOnlyHash_NeverPlaintext()
{
ApiKey? persisted = null;
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => persisted = k))
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The response must carry the one-time plaintext key shown to the operator.
var plaintext = ExtractPlaintextKey(response.JsonData);
Assert.False(string.IsNullOrWhiteSpace(plaintext));
// The stored entity must carry a hash, never the plaintext.
Assert.NotNull(persisted);
Assert.NotEqual(plaintext, persisted!.KeyHash);
// The persisted hash must equal the peppered hash of the returned plaintext.
var hasher = new ApiKeyHasher(Pepper);
Assert.Equal(hasher.Hash(plaintext!), persisted.KeyHash);
}
[Fact]
public void CreateApiKey_ResponseDoesNotEchoTheHash()
{
ApiKey? persisted = null;
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => persisted = k))
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(persisted);
// The serialized response must not leak the stored hash as a usable artifact.
Assert.DoesNotContain(persisted!.KeyHash, response.JsonData);
}
[Fact]
public void CreateApiKey_TwoKeys_GenerateDistinctRandomValues()
{
var hashes = new List<string>();
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => hashes.Add(k.KeyHash)))
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("KeyA"), "Admin"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new CreateApiKeyCommand("KeyB"), "Admin"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(2, hashes.Count);
Assert.NotEqual(hashes[0], hashes[1]);
}
/// <summary>
/// The create response is JSON carrying the one-time plaintext key under a
/// <c>PlaintextKey</c> (or <c>Key</c>) property.
/// </summary>
private static string? ExtractPlaintextKey(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("plaintextKey", out var p) || root.TryGetProperty("PlaintextKey", out p))
return p.GetString();
if (root.TryGetProperty("key", out var k) || root.TryGetProperty("Key", out k))
return k.GetString();
return null;
}
}
@@ -0,0 +1,610 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>).
///
/// <para>
/// ManagementService authenticates each request by hand (HTTP Basic → LDAP →
/// roles) rather than via the ASP.NET authorization-policy pipeline, so these
/// tests substitute <see cref="LdapAuthService"/> + <see cref="RoleMapper"/>
/// (both expose a virtual test seam) to drive the role outcome without standing
/// up a directory. The repository is a stubbed <see cref="IAuditLogRepository"/>.
/// </para>
/// </summary>
public class AuditEndpointsTests
{
private const string BasicCredential = "auditor:password";
private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) => new()
{
EventId = id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"),
OccurredAtUtc = occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
SourceSiteId = "plant-a",
Status = AuditStatus.Delivered,
HttpStatus = 200,
};
/// <summary>
/// Builds an in-process TestServer hosting the audit endpoints with stubbed
/// auth + repository. <paramref name="roles"/> is the role set the
/// substituted <see cref="RoleMapper"/> returns for the authenticated user;
/// pass an empty array to simulate a user with no audit permission.
/// </summary>
private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync(
string[] roles,
IReadOnlyList<AuditEvent>[]? queryPages = null,
bool ldapSucceeds = true)
{
var repo = Substitute.For<IAuditLogRepository>();
if (queryPages is { Length: > 0 })
{
var returns = queryPages
.Select(p => Task.FromResult<IReadOnlyList<AuditEvent>>(p))
.ToArray();
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(returns[0], returns.Skip(1).ToArray());
}
else
{
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
}
// Substituted LDAP bind — AuthenticateAsync is virtual (test seam).
var ldap = Substitute.For<LdapAuthService>(
Options.Create(new SecurityOptions()),
Substitute.For<ILogger<LdapAuthService>>());
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ldapSucceeds
? new LdapAuthResult(true, "Auditor", "auditor", new[] { "cn=audit" }, null)
: new LdapAuthResult(false, null, null, null, "Bad credentials."));
// Substituted role mapper — MapGroupsToRolesAsync is virtual (test seam).
var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));
var hostBuilder = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddRouting();
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
});
web.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
});
});
var host = await hostBuilder.StartAsync();
return (host.GetTestClient(), repo, host);
}
private static HttpRequestMessage Get(string url, string credential = BasicCredential)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (credential.Length > 0)
{
request.Headers.Authorization = new AuthenticationHeaderValue(
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
}
return request;
}
// ─────────────────────────────────────────────────────────────────────
// /api/audit/query
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Query_ValidParams_ReturnsJsonPage()
{
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" },
queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } });
using (host)
{
var response = await client.SendAsync(Get(
"/api/audit/query?channel=ApiOutbound&status=Delivered&pageSize=100"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var events = doc.RootElement.GetProperty("events");
Assert.Equal(1, events.GetArrayLength());
Assert.Equal("11111111-1111-1111-1111-111111111111",
events[0].GetProperty("eventId").GetString());
// A short page (1 row < pageSize 100) means no further pages.
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("nextCursor").ValueKind);
}
}
[Fact]
public async Task Query_WithCursor_ReturnsNextPage()
{
// First page is FULL (pageSize=2 → 2 rows) so the response carries a
// non-null nextCursor; the test then replays that cursor as the next
// request and asserts the repo saw a keyset-paged AuditLogPaging.
var pageOne = (IReadOnlyList<AuditEvent>)new[]
{
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"),
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"),
new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)),
};
var (client, repo, host) = await BuildHostAsync(
roles: new[] { "Audit" },
queryPages: new[] { pageOne });
using (host)
{
var first = await client.SendAsync(Get("/api/audit/query?pageSize=2"));
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
using var firstDoc = JsonDocument.Parse(await first.Content.ReadAsStringAsync());
var cursor = firstDoc.RootElement.GetProperty("nextCursor");
Assert.Equal(JsonValueKind.Object, cursor.ValueKind);
var afterEventId = cursor.GetProperty("afterEventId").GetString()!;
var afterOccurredAt = cursor.GetProperty("afterOccurredAtUtc").GetString()!;
Assert.Equal("aaaaaaaa-0000-0000-0000-000000000002", afterEventId);
// Replay the cursor — the endpoint must thread it into AuditLogPaging.
var second = await client.SendAsync(Get(
$"/api/audit/query?pageSize=2&afterEventId={afterEventId}&afterOccurredAtUtc={Uri.EscapeDataString(afterOccurredAt)}"));
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
await repo.Received().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Is<AuditLogPaging>(p =>
p.PageSize == 2 &&
p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") &&
p.AfterOccurredAtUtc != null),
Arg.Any<CancellationToken>());
}
}
[Fact]
public async Task Query_WithoutOperationalAudit_Returns403()
{
// A user whose only role is Design holds neither OperationalAudit nor
// AuditExport — the query endpoint must 403.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/query"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task Query_WithoutCredentials_Returns401()
{
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/query", credential: ""));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
[Fact]
public async Task Query_AuditReadOnlyRole_IsAllowed()
{
// AuditReadOnly satisfies OperationalAudit (read) — query must succeed.
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/query"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}
// ─────────────────────────────────────────────────────────────────────
// /api/audit/export
// ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Export_Csv_StreamsContent_WithCsvContentType()
{
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" },
queryPages: new[]
{
(IReadOnlyList<AuditEvent>)new[] { SampleEvent() },
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
});
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
var disposition = response.Content.Headers.ContentDisposition;
Assert.NotNull(disposition);
Assert.Equal("attachment", disposition!.DispositionType);
Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);
var body = await response.Content.ReadAsStringAsync();
Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
Assert.Contains("11111111-1111-1111-1111-111111111111", body);
}
}
[Fact]
public async Task Export_Csv_DefaultsWhenFormatOmitted()
{
// No format= param → csv default.
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" },
queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
}
}
[Fact]
public async Task Export_Jsonl_StreamsOnePerLine()
{
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Audit" },
queryPages: new[]
{
(IReadOnlyList<AuditEvent>)new[]
{
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")),
SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
},
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
});
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=jsonl"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType!.MediaType);
var body = await response.Content.ReadAsStringAsync();
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
// Each line must be a standalone, parseable JSON object.
foreach (var line in lines)
{
using var doc = JsonDocument.Parse(line);
Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
Assert.True(doc.RootElement.TryGetProperty("eventId", out _));
}
}
}
[Fact]
public async Task Export_Parquet_Returns501()
{
// Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
// library is referenced, so the endpoint returns 501 with guidance.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("Parquet export deferred to v1.x", body);
}
}
[Fact]
public async Task Export_WithoutAuditExport_Returns403()
{
// AuditReadOnly grants read (OperationalAudit) but NOT bulk export
// (AuditExport) — the export endpoint must 403.
var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
[Fact]
public async Task Export_UnsupportedFormat_Returns400()
{
var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" });
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=xml"));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
// ─────────────────────────────────────────────────────────────────────
// Query-string parsing units
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void ParsePaging_ClampsPageSizeToMax()
{
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["pageSize"] = "999999",
});
var paging = AuditEndpoints.ParsePaging(query);
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
}
[Fact]
public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists()
{
// Repeated query params (channel=A&channel=B …) must widen to multi-value
// filter lists — one element per supplied value.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["channel"] = new[] { "ApiOutbound", "DbOutbound" },
["kind"] = new[] { "ApiCall", "DbWrite" },
["status"] = new[] { "Failed", "Parked" },
["sourceSiteId"] = new[] { "plant-a", "plant-b" },
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, filter.Channels);
Assert.Equal(new[] { AuditKind.ApiCall, AuditKind.DbWrite }, filter.Kinds);
Assert.Equal(new[] { AuditStatus.Failed, AuditStatus.Parked }, filter.Statuses);
Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds);
}
[Fact]
public void ParseFilter_SingleParam_ParsesIntoOneElementList()
{
// The single-valued contract still holds — one param yields a
// one-element list, not a scalar.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["channel"] = "ApiOutbound",
["status"] = "Delivered",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
Assert.Equal(new[] { AuditStatus.Delivered }, filter.Statuses);
Assert.Null(filter.Kinds);
Assert.Null(filter.SourceSiteIds);
}
[Fact]
public void ParseFilter_UnparseableValuesInRepeatedSet_AreDroppedSilently()
{
// Lax-parse contract: an unrecognised enum name is dropped, the rest of
// the repeated set survives — no 400, no whole-set drop.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" },
["status"] = new[] { "Nonsense" },
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, filter.Channels);
// Every value unparseable → the dimension stays unconstrained (null).
Assert.Null(filter.Statuses);
}
[Fact]
public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid()
{
// executionId is a single-value Guid? filter — mirrors correlationId.
var executionId = Guid.NewGuid();
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["executionId"] = executionId.ToString(),
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(executionId, filter.ExecutionId);
}
[Fact]
public void ParseFilter_UnparseableExecutionId_IsDroppedSilently()
{
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
// mirrors the correlationId parse.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["executionId"] = "not-a-guid",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Null(filter.ExecutionId);
}
[Fact]
public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid()
{
// parentExecutionId is a single-value Guid? filter — mirrors executionId.
var parentExecutionId = Guid.NewGuid();
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = parentExecutionId.ToString(),
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(parentExecutionId, filter.ParentExecutionId);
}
[Fact]
public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently()
{
// Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) —
// mirrors the executionId parse.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = "not-a-guid",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Null(filter.ParentExecutionId);
}
[Fact]
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
{
// End-to-end: a repeated channel= query param must surface at the
// repository as a two-element Channels list.
var (client, repo, host) = await BuildHostAsync(roles: new[] { "Audit" });
using (host)
{
var response = await client.SendAsync(Get(
"/api/audit/query?channel=ApiOutbound&channel=DbOutbound"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f =>
f.Channels != null && f.Channels.Count == 2 &&
f.Channels.Contains(AuditChannel.ApiOutbound) &&
f.Channels.Contains(AuditChannel.DbOutbound)),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[Fact]
public void ParsePaging_HalfSuppliedCursor_IsDropped()
{
// afterEventId without afterOccurredAtUtc is an invalid keyset cursor —
// both must be dropped so the repository gets a first-page request.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["afterEventId"] = Guid.NewGuid().ToString(),
});
var paging = AuditEndpoints.ParsePaging(query);
Assert.Null(paging.AfterEventId);
Assert.Null(paging.AfterOccurredAtUtc);
}
// ─────────────────────────────────────────────────────────────────────
// ApplySiteScope (Management-019)
// ─────────────────────────────────────────────────────────────────────
[Fact]
public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
{
// Empty PermittedSiteIds is the system-wide signal (Admin, system-wide
// Deployment, audit roles with no scope rules attached). The filter
// should pass through with no restriction added.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "Admin" }, Array.Empty<string>());
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Same(filter, result);
}
[Fact]
public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet()
{
// No explicit sourceSiteId from the caller — the helper must restrict
// the query to the user's permitted set, otherwise a site-scoped audit
// user could read every site's rows.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter();
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.NotNull(result!.SourceSiteIds);
Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray());
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
{
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
[Fact]
public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull()
{
// Caller explicitly asked for a site they cannot see — the helper signals
// "403" by returning null rather than silently producing an empty page.
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.Null(result);
}
[Fact]
public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
{
var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
"alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" });
var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" });
var result = AuditEndpoints.ApplySiteScope(filter, user);
Assert.NotNull(result);
Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
}
}
@@ -0,0 +1,66 @@
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Tests for <see cref="DebugStreamHub"/> per-instance site-scope authorization
/// (finding ManagementService-003).
/// </summary>
public class DebugStreamHubTests
{
[Fact]
public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed()
{
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Deployment" },
permittedSiteIds: new[] { "1", "2" },
instanceSiteId: 2);
Assert.True(allowed);
}
[Fact]
public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied()
{
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Deployment" },
permittedSiteIds: new[] { "1", "2" },
instanceSiteId: 99);
Assert.False(allowed);
}
[Fact]
public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed()
{
// Empty permitted set == system-wide Deployment.
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Deployment" },
permittedSiteIds: Array.Empty<string>(),
instanceSiteId: 99);
Assert.True(allowed);
}
[Fact]
public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope()
{
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "Admin", "Deployment" },
permittedSiteIds: new[] { "1" },
instanceSiteId: 99);
Assert.True(allowed);
}
[Fact]
public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive()
{
var allowed = DebugStreamHub.IsInstanceAccessAllowed(
roles: new[] { "admin" },
permittedSiteIds: new[] { "1" },
instanceSiteId: 99);
Assert.True(allowed);
}
}
@@ -0,0 +1,39 @@
using Akka.Actor;
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Tests for the <see cref="ManagementActor"/> supervision strategy
/// (finding ManagementService-005). The project convention is that long-lived
/// coordinator-style actors declare an explicit Resume-based strategy.
/// </summary>
public class ManagementActorSupervisionTests
{
[Fact]
public void CreateSupervisorStrategy_ReturnsOneForOneStrategy()
{
var strategy = ManagementActor.CreateSupervisorStrategy();
Assert.IsType<OneForOneStrategy>(strategy);
}
[Fact]
public void CreateSupervisorStrategy_ResumesOnArbitraryException()
{
var strategy = (OneForOneStrategy)ManagementActor.CreateSupervisorStrategy();
var directive = strategy.Decider.Decide(new InvalidOperationException("boom"));
Assert.Equal(Directive.Resume, directive);
}
[Fact]
public void CreateSupervisorStrategy_ResumesIndefinitely()
{
var strategy = (OneForOneStrategy)ManagementActor.CreateSupervisorStrategy();
// Coordinator actors should not give up: unbounded retries.
Assert.Equal(-1, strategy.MaxNumberOfRetries);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Tests for <see cref="ManagementEndpoints"/> request-body parsing
/// (findings ManagementService-006 / -013).
/// </summary>
public class ManagementEndpointsTests
{
[Fact]
public void ParseCommand_WithExplicitPayload_DeserializesIntoCommandType()
{
var json = """{ "command": "CreateSite", "payload": { "name": "Site1", "siteIdentifier": "SITE1", "description": "Desc" } }""";
var result = ManagementEndpoints.ParseCommand(json);
Assert.True(result.Success);
var command = Assert.IsType<CreateSiteCommand>(result.Command);
Assert.Equal("Site1", command.Name);
Assert.Equal("SITE1", command.SiteIdentifier);
Assert.Equal("Desc", command.Description);
}
[Fact]
public void ParseCommand_WithMissingPayload_DeserializesParameterlessCommand()
{
// No "payload" field at all -- the fallback must not allocate a throwaway
// JsonDocument and must still produce a valid parameterless command.
var json = """{ "command": "ListTemplates" }""";
var result = ManagementEndpoints.ParseCommand(json);
Assert.True(result.Success);
Assert.IsType<ListTemplatesCommand>(result.Command);
}
[Fact]
public void ParseCommand_WithInvalidJson_ReturnsFailure()
{
var result = ManagementEndpoints.ParseCommand("{ not json");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
}
[Fact]
public void ParseCommand_WithMissingCommandField_ReturnsFailure()
{
var result = ManagementEndpoints.ParseCommand("""{ "payload": {} }""");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
}
[Fact]
public void ParseCommand_WithUnknownCommand_ReturnsFailure()
{
var result = ManagementEndpoints.ParseCommand("""{ "command": "NoSuchCommand" }""");
Assert.False(result.Success);
Assert.Equal("BAD_REQUEST", result.ErrorCode);
}
// ========================================================================
// Command-timeout configuration (finding ManagementService-010)
//
// ManagementServiceOptions.CommandTimeout must actually drive the Ask
// timeout instead of a hard-coded 30s constant.
// ========================================================================
[Fact]
public void ResolveAskTimeout_UsesConfiguredCommandTimeout()
{
var options = new ManagementServiceOptions { CommandTimeout = TimeSpan.FromSeconds(75) };
var timeout = ManagementEndpoints.ResolveAskTimeout(options);
Assert.Equal(TimeSpan.FromSeconds(75), timeout);
}
[Fact]
public void ResolveAskTimeout_WithNullOptions_FallsBackToDefault()
{
var timeout = ManagementEndpoints.ResolveAskTimeout(null);
Assert.Equal(TimeSpan.FromSeconds(30), timeout);
}
[Fact]
public void ResolveAskTimeout_WithNonPositiveTimeout_FallsBackToDefault()
{
// A misconfigured zero/negative timeout would make every Ask fail
// immediately; fall back to the safe default instead.
var options = new ManagementServiceOptions { CommandTimeout = TimeSpan.Zero };
var timeout = ManagementEndpoints.ResolveAskTimeout(options);
Assert.Equal(TimeSpan.FromSeconds(30), timeout);
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj" />
</ItemGroup>
</Project>