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,49 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
/// <summary>
/// ConfigurationDatabase-012: the <see cref="ApiKey"/> entity must never carry the
/// plaintext bearer credential as a persisted field — only its deterministic hash.
/// </summary>
public class ApiKeyTests
{
[Fact]
public void ApiKey_HasNoPlaintextKeyValueProperty()
{
// The plaintext key is shown to the operator once at creation and is never
// persisted. The entity must therefore expose KeyHash, not KeyValue.
var properties = typeof(ApiKey).GetProperties().Select(p => p.Name).ToArray();
Assert.DoesNotContain("KeyValue", properties);
Assert.Contains("KeyHash", properties);
}
[Fact]
public void Constructor_FromPlaintext_StoresHashNotPlaintext()
{
var key = new ApiKey("MES-Production", "the-secret-key-value");
Assert.NotEqual("the-secret-key-value", key.KeyHash);
Assert.Equal(ApiKeyHasher.Default.Hash("the-secret-key-value"), key.KeyHash);
}
[Fact]
public void FromHash_StoresHashVerbatim()
{
var key = ApiKey.FromHash("RecipeManager-Dev", "precomputed-hash-value");
Assert.Equal("RecipeManager-Dev", key.Name);
Assert.Equal("precomputed-hash-value", key.KeyHash);
}
[Fact]
public void Constructor_NullArguments_Throw()
{
Assert.Throws<ArgumentNullException>(() => new ApiKey(null!, "value"));
Assert.Throws<ArgumentNullException>(() => new ApiKey("name", (string)null!));
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash(null!, "hash"));
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash("name", null!));
}
}
@@ -0,0 +1,163 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities.Audit;
/// <summary>
/// Verifies <see cref="AuditEvent"/> behaves as an init-only record:
/// every property reads back as constructed, and <c>with</c> expressions
/// produce a new instance with a single property changed.
/// </summary>
public class AuditEventTests
{
[Fact]
public void Construction_AllPropertiesReadBack()
{
var eventId = Guid.NewGuid();
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
var corrId = Guid.NewGuid();
var execId = Guid.NewGuid();
var evt = new AuditEvent
{
EventId = eventId,
OccurredAtUtc = occurredAt,
IngestedAtUtc = ingestedAt,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = corrId,
ExecutionId = execId,
SourceSiteId = "site-01",
SourceInstanceId = "inst-7",
SourceScript = "OnAlarm",
Actor = "system",
Target = "WeatherAPI",
Status = AuditStatus.Delivered,
HttpStatus = 200,
DurationMs = 42,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = "GET /forecast",
ResponseSummary = "{\"temp\":21}",
PayloadTruncated = false,
Extra = "{}",
ForwardState = AuditForwardState.Forwarded
};
Assert.Equal(eventId, evt.EventId);
Assert.Equal(occurredAt, evt.OccurredAtUtc);
Assert.Equal(ingestedAt, evt.IngestedAtUtc);
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(corrId, evt.CorrelationId);
Assert.Equal(execId, evt.ExecutionId);
Assert.Equal("site-01", evt.SourceSiteId);
Assert.Equal("inst-7", evt.SourceInstanceId);
Assert.Equal("OnAlarm", evt.SourceScript);
Assert.Equal("system", evt.Actor);
Assert.Equal("WeatherAPI", evt.Target);
Assert.Equal(AuditStatus.Delivered, evt.Status);
Assert.Equal(200, evt.HttpStatus);
Assert.Equal(42, evt.DurationMs);
Assert.Null(evt.ErrorMessage);
Assert.Null(evt.ErrorDetail);
Assert.Equal("GET /forecast", evt.RequestSummary);
Assert.Equal("{\"temp\":21}", evt.ResponseSummary);
Assert.False(evt.PayloadTruncated);
Assert.Equal("{}", evt.Extra);
Assert.Equal(AuditForwardState.Forwarded, evt.ForwardState);
}
[Fact]
public void NullableProperties_AcceptNull()
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
IngestedAtUtc = null,
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
CorrelationId = null,
ExecutionId = null,
SourceSiteId = null,
SourceInstanceId = null,
SourceScript = null,
Actor = null,
Target = null,
Status = AuditStatus.Submitted,
HttpStatus = null,
DurationMs = null,
ErrorMessage = null,
ErrorDetail = null,
RequestSummary = null,
ResponseSummary = null,
PayloadTruncated = false,
Extra = null,
ForwardState = null
};
Assert.Null(evt.IngestedAtUtc);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript);
Assert.Null(evt.Actor);
Assert.Null(evt.Target);
Assert.Null(evt.HttpStatus);
Assert.Null(evt.DurationMs);
Assert.Null(evt.ErrorMessage);
Assert.Null(evt.ErrorDetail);
Assert.Null(evt.RequestSummary);
Assert.Null(evt.ResponseSummary);
Assert.Null(evt.Extra);
Assert.Null(evt.ForwardState);
}
[Fact]
public void AuditEvent_carries_SourceNode_through_init()
{
// SourceNode identifies the cluster node that emitted the event (site
// node-a/node-b or central-a/central-b). It's an additive nullable
// init-only property — defaults to null when omitted, round-trips its
// value when set, and is preserved through `with` expressions.
var evtDefault = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Submitted,
PayloadTruncated = false
};
Assert.Null(evtDefault.SourceNode);
var evtStamped = evtDefault with { SourceNode = "node-a" };
Assert.Equal("node-a", evtStamped.SourceNode);
Assert.Null(evtDefault.SourceNode);
}
[Fact]
public void With_ProducesNewInstance_WithSingleFieldChanged()
{
var original = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Submitted,
PayloadTruncated = false
};
var updated = original with { Status = AuditStatus.Delivered };
Assert.NotSame(original, updated);
Assert.Equal(AuditStatus.Submitted, original.Status);
Assert.Equal(AuditStatus.Delivered, updated.Status);
Assert.Equal(original.EventId, updated.EventId);
Assert.NotEqual(original, updated);
}
}
@@ -0,0 +1,40 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities.Audit;
/// <summary>
/// Verifies the <see cref="SiteCall"/> central operational entity carries the
/// SourceNode column (additive, nullable) through init-only construction and
/// <c>with</c> expressions. Sibling to <see cref="AuditEventTests"/>.
/// </summary>
public class SiteCallTests
{
private static SiteCall MinimalRow() => new()
{
TrackedOperationId = TrackedOperationId.New(),
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = "site-01",
Status = "Submitted",
RetryCount = 0,
CreatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc),
UpdatedAtUtc = new DateTime(2026, 5, 23, 12, 0, 0, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 23, 12, 0, 1, DateTimeKind.Utc),
};
[Fact]
public void SiteCall_carries_SourceNode()
{
// SourceNode identifies the cluster node that emitted the cached call
// (site node-a/node-b or central-a/central-b). Additive nullable init
// property — defaults to null on rows ingested before the column
// existed, and round-trips its value via `with` expressions.
var row = MinimalRow();
Assert.Null(row.SourceNode);
var stamped = row with { SourceNode = "node-b" };
Assert.Equal("node-b", stamped.SourceNode);
Assert.Null(row.SourceNode);
}
}
@@ -0,0 +1,89 @@
using System.Collections;
using System.Reflection;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
public class EntityConventionTests
{
private static readonly Assembly CommonsAssembly = typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.RetryPolicy).Assembly;
private static IEnumerable<Type> GetEntityTypes() =>
CommonsAssembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && t.Namespace != null
&& t.Namespace.Contains(".Entities."));
[Fact]
public void AllEntities_ShouldHaveNoEfAttributes()
{
var efAttributeNames = new HashSet<string>
{
"KeyAttribute", "ForeignKeyAttribute", "TableAttribute",
"ColumnAttribute", "RequiredAttribute", "MaxLengthAttribute",
"StringLengthAttribute", "DatabaseGeneratedAttribute",
"NotMappedAttribute", "IndexAttribute", "InversePropertyAttribute"
};
foreach (var entityType in GetEntityTypes())
{
// Check class-level attributes
var classAttrs = entityType.GetCustomAttributes(true);
foreach (var attr in classAttrs)
{
Assert.DoesNotContain(attr.GetType().Name, efAttributeNames);
}
// Check property-level attributes
foreach (var prop in entityType.GetProperties())
{
var propAttrs = prop.GetCustomAttributes(true);
foreach (var attr in propAttrs)
{
Assert.False(efAttributeNames.Contains(attr.GetType().Name),
$"Entity {entityType.Name}.{prop.Name} has EF attribute {attr.GetType().Name}");
}
}
}
}
[Fact]
public void AllTimestampProperties_ShouldBeDateTimeOffset()
{
var timestampNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"Timestamp", "DeployedAt", "CompletedAt", "GeneratedAt",
"ReportTimestamp", "SnapshotTimestamp"
};
foreach (var entityType in GetEntityTypes())
{
foreach (var prop in entityType.GetProperties())
{
if (timestampNames.Contains(prop.Name))
{
var underlyingType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
Assert.True(underlyingType == typeof(DateTimeOffset),
$"{entityType.Name}.{prop.Name} should be DateTimeOffset but is {prop.PropertyType.Name}");
}
}
}
}
[Fact]
public void NavigationProperties_ShouldBeICollection()
{
foreach (var entityType in GetEntityTypes())
{
foreach (var prop in entityType.GetProperties())
{
if (prop.PropertyType.IsGenericType &&
typeof(IEnumerable).IsAssignableFrom(prop.PropertyType) &&
prop.PropertyType != typeof(string))
{
Assert.True(
prop.PropertyType.GetGenericTypeDefinition() == typeof(ICollection<>),
$"{entityType.Name}.{prop.Name} should be ICollection<T> but is {prop.PropertyType.Name}");
}
}
}
}
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
/// <summary>
/// Verifies the <see cref="Notification"/> outbox entity's constructor defaults
/// and null-argument guards on required reference-type parameters.
/// </summary>
public class NotificationEntityTests
{
[Fact]
public void Constructor_SetsDefaults()
{
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Equal(NotificationStatus.Pending, n.Status);
Assert.Equal(0, n.RetryCount);
Assert.Equal("id-1", n.NotificationId);
Assert.Equal(NotificationType.Email, n.Type);
Assert.Equal("ops-team", n.ListName);
Assert.Equal("SiteA", n.SourceSiteId);
}
[Fact]
public void OriginExecutionId_DefaultsToNull_AndIsSettable()
{
// Audit Log #23: OriginExecutionId carries the originating script
// execution's id from the site so the dispatcher can echo it onto
// NotifyDeliver rows. Null for notifications submitted before the
// column existed; settable from the NotificationSubmit message.
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Null(n.OriginExecutionId);
var executionId = Guid.NewGuid();
n.OriginExecutionId = executionId;
Assert.Equal(executionId, n.OriginExecutionId);
}
[Fact]
public void OriginParentExecutionId_DefaultsToNull_AndIsSettable()
{
// Audit Log ParentExecutionId: OriginParentExecutionId carries the
// routed run's parent ExecutionId from the site so the dispatcher can
// echo it onto NotifyDeliver rows. Null for non-routed runs, or for
// notifications submitted before the column existed.
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Null(n.OriginParentExecutionId);
var parentExecutionId = Guid.NewGuid();
n.OriginParentExecutionId = parentExecutionId;
Assert.Equal(parentExecutionId, n.OriginParentExecutionId);
}
[Fact]
public void SourceNode_DefaultsToNull_AndIsSettable()
{
// SourceNode identifies the cluster node that emitted the notification
// (site node-a/node-b or central-a/central-b). Additive nullable
// property — defaults to null on rows submitted before the column
// existed, and round-trips its value when set.
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Null(n.SourceNode);
n.SourceNode = "node-a";
Assert.Equal("node-a", n.SourceNode);
}
[Fact]
public void Constructor_NullArguments_Throw()
{
Assert.Throws<ArgumentNullException>(() => new Notification(null!, NotificationType.Email, "list", "s", "b", "SiteA"));
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, null!, "s", "b", "SiteA"));
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, "list", null!, "b", "SiteA"));
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, "list", "s", null!, "SiteA"));
Assert.Throws<ArgumentNullException>(() => new Notification("id", NotificationType.Email, "list", "s", "b", null!));
}
}
@@ -0,0 +1,30 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
public class TemplateFolderTests
{
[Fact]
public void Constructor_SetsName()
{
var folder = new TemplateFolder("Dev");
Assert.Equal("Dev", folder.Name);
Assert.Null(folder.ParentFolderId);
Assert.Equal(0, folder.SortOrder);
}
[Fact]
public void Constructor_NullName_Throws()
{
Assert.Throws<ArgumentNullException>(() => new TemplateFolder(null!));
}
[Fact]
public void Template_FolderId_DefaultsToNull()
{
var template = new Template("X");
Assert.Null(template.FolderId);
}
}