fix(correctness): close Theme 10 — 5 data-integrity / serialisation findings

Final themed batch. 5 well-localised correctness fixes.

Serialisation precision:
- ESG-020: DatabaseGateway.JsonElementToParameterValue probes
  TryGetInt64 → TryGetDecimal → GetDouble, so a script's high-precision
  decimal SQL parameter survives the cached-write retry round-trip
  without silent precision loss. 3 new regression tests.

Template engine correctness:
- TE-018: DiffService gains ComputeConnectionsDiff over
  FlattenedConfiguration.Connections, mirroring the existing entity-diff
  shape and pairing with the Theme 1 TE-017 hash-coverage fix. A
  ConfigurationDiff record extension in Commons is flagged as a follow-up.
- TE-019: TemplateResolver.BuildInheritanceChain now walks via the
  int? ParentTemplateId directly — only null means "no parent". A real
  Id of 0 (the prior special-cased sentinel) now walks the chain like
  any other node, matching the TemplateEngine-013 CycleDetector fix.
  Regression of TE-013 closed.
- TE-020: All 5 Create* paths in TemplateService + SharedScriptService
  re-ordered to save-first → log-with-real-Id → save-audit (matching
  the InstanceService pattern). Create* audit rows no longer carry a
  literal "0" EntityId.

Doc deferral:
- Transport-012: Component-Transport.md §Audit Trail now spells out that
  the BundleImportId repository filter IS wired (in CentralUiRepository),
  but the Audit-Log-Viewer UI dropdown + summary-row hyperlink are a
  deferred CentralUI follow-up. CLI workaround documented
  (audit query --bundle-import-id).

11+ new regression tests (3 ESG, 4 DiffService, 3 TemplateResolver, 4
TemplateService, 1 SharedScriptService). Build clean; ESG 72/72,
TemplateEngine 324/324. README regenerated: 1 pending of 481 total.

Session-to-date: 135 of 136 originally-open Theme findings closed
across 10 themes in 10 commits.
This commit is contained in:
Joseph Doherty
2026-05-28 08:48:44 -04:00
parent 77cb0ad0e2
commit 11950b0a8e
15 changed files with 620 additions and 42 deletions
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 1 |
| Open findings | 0 |
## Summary
@@ -1118,9 +1118,11 @@ and produces a `"Timeout calling..."` (not `"Connection error to..."`) error.
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:185-193` |
**Resolution (2026-05-28):** `JsonElementToParameterValue` now probes `TryGetInt64``TryGetDecimal``GetDouble`, so a JSON number that fits in `decimal` materialises as a `decimal` (preserving the script's authored precision on cached-write retries) and only genuinely out-of-decimal-range values fall through to `double`. Regression test `JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal` round-trips `1234567890.1234567890` through a `JsonElement` and asserts the result is a `decimal` carrying the original precision; companion tests guard the long-fast-path and the out-of-range-double fallback.
**Description**
`DatabaseGateway.JsonElementToParameterValue` materialises the buffered cached-write
+9 -15
View File
@@ -41,9 +41,9 @@ module file and counted in **Total**.
|----------|---------------|
| Critical | 0 |
| High | 0 |
| Medium | 5 |
| Low | 1 |
| **Total** | **6** |
| Medium | 1 |
| Low | 0 |
| **Total** | **1** |
## Module Status
@@ -58,7 +58,7 @@ module file and counted in **Total**.
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 24 |
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 24 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/0 | 1 | 23 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 23 |
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 23 |
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 25 |
@@ -70,8 +70,8 @@ module file and counted in **Total**.
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 26 |
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 24 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/0 | 3 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 12 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 12 |
## Pending Findings
@@ -88,18 +88,12 @@ _None open._
_None open._
### Medium (5)
### Medium (1)
| ID | Module | Title |
|----|--------|-------|
| AuditLog-001 | [AuditLog](AuditLog/findings.md) | Combined-telemetry transport is plumbed end-to-end but never invoked in production |
| ExternalSystemGateway-020 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `JsonElementToParameterValue` silently downcasts non-Int64 JSON numbers to `double`, losing precision for `decimal` SQL parameters on retry |
| TemplateEngine-018 | [TemplateEngine](TemplateEngine/findings.md) | `DiffService` reports no entries for added/removed/changed connections |
| TemplateEngine-019 | [TemplateEngine](TemplateEngine/findings.md) | `TemplateResolver.BuildInheritanceChain` still uses the `0`-as-no-parent sentinel that was removed from `CycleDetector` |
| TemplateEngine-020 | [TemplateEngine](TemplateEngine/findings.md) | `Create*` audit entries are written with `EntityId = "0"` before `SaveChangesAsync` populates the real key |
### Low (1)
### Low (0)
| ID | Module | Title |
|----|--------|-------|
| Transport-012 | [Transport](Transport/findings.md) | "Bundle Import" filter promised in design doc not surfaced in Configuration Audit Log Viewer UI |
_None open._
+10 -4
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 |
| Open findings | 1 |
## Summary
@@ -913,9 +913,11 @@ TemplateEngine-018.
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/Flattening/DiffService.cs:19` |
**Resolution (2026-05-28):** Added `DiffService.ComputeConnectionsDiff(oldConfig, newConfig)`, which mirrors the existing attribute/alarm/script `ComputeEntityDiff` shape over the `FlattenedConfiguration.Connections` map and emits `DiffEntry<ConnectionConfig>` Added / Removed / Changed entries keyed by connection name (delegating equality to the existing `ConnectionsEqual` helper). Kept the new diff as a parallel method on `DiffService` rather than extending `ConfigurationDiff` (which lives in `ScadaLink.Commons`, outside this fix's scope); the public-record extension and Central UI plumbing are a paired Commons follow-up. Regression tests: `ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded`, `ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved`, `ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged`, `ComputeConnectionsDiff_IdenticalConnections_NoEntries`.
**Description**
`DiffService.ComputeDiff` returns a `ConfigurationDiff` with `AttributeChanges`,
@@ -954,9 +956,11 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateResolver.cs:117`, `src/ScadaLink.TemplateEngine/TemplateResolver.cs:123` |
**Resolution (2026-05-28):** `BuildInheritanceChain` now walks the parent chain via the `int?` `ParentTemplateId` directly — only a missing (`null`) value means "no parent", so a real template Id of 0 walks the chain like any other node (matching the duplicate-tolerant `BuildLookup` and the TemplateEngine-013 `CycleDetector` fix). Regression tests: `BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent`, `BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero`, and the end-to-end `ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers`.
**Description**
TemplateEngine-013 removed the `0`-as-no-parent sentinel from `CycleDetector`
@@ -1014,9 +1018,11 @@ _Unresolved._
|--|--|
| Severity | Medium |
| Category | Code organization & conventions |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:77`, `src/ScadaLink.TemplateEngine/TemplateService.cs:256`, `src/ScadaLink.TemplateEngine/TemplateService.cs:407`, `src/ScadaLink.TemplateEngine/TemplateService.cs:556`, `src/ScadaLink.TemplateEngine/TemplateService.cs:734`, `src/ScadaLink.TemplateEngine/SharedScriptService.cs:71` |
**Resolution (2026-05-28):** Every `Create*` path in `TemplateService` (`CreateTemplateAsync`, `AddAttributeAsync`, `AddAlarmAsync`, `AddScriptAsync`, `AddCompositionAsync`) and `SharedScriptService.CreateSharedScriptAsync` now follows the `InstanceService.CreateInstanceAsync` shape — save the entity first so EF Core populates the auto-generated key, then log the audit row with the real `entity.Id`, then save the audit row. `AddCompositionAsync` already saved the composition row inside `CreateCascadedCompositionAsync` before returning, so only its `LogAsync` call needed to switch from `"0"` to `composition.Id.ToString()`. Regression tests assert the captured audit `entityId` equals the post-save id (not `"0"`): `CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero`, `AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero`, `AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero`, `AddScript_AuditRowCarriesRealScriptIdNotLiteralZero`, and `CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero`.
**Description**
`IAuditService.LogAsync` takes a `string entityId` argument and `TemplateService
+9 -3
View File
@@ -499,7 +499,7 @@ bundle prompt is surfaced AFTER the manifest+hash check, and that a dedicated
|--|--|
| Severity | Low |
| Category | Documentation & comments |
| Status | Open |
| Status | Resolved |
| Location | `docs/requirements/Component-Transport.md` §Audit Trail, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:148` |
**Description**
@@ -518,6 +518,12 @@ it's reasonable to flag.
Either implement the filter dropdown + summary-row link in the Configuration
Audit Log Viewer, or note the deferral in the design doc.
**Resolution**
**Resolution (2026-05-28):**
_Unresolved._
Took the "note the deferral in the design doc" path. `docs/requirements/Component-Transport.md`
§Audit Trail's "Correlation:" paragraph now spells out that the repository filter on
`BundleImportId` IS wired (in `CentralUiRepository.QueryConfigurationAuditAsync`)
but the Audit-Log-Viewer UI surface — the dropdown + `BundleImported` hyperlink —
is a deferred UI follow-up. Operators have a workaround via the existing
`audit query --bundle-import-id` CLI flag. The UI work belongs in the CentralUI
backlog; implementing it here would expand scope beyond a doc fix.
+1 -1
View File
@@ -257,7 +257,7 @@ Import flows through the same audited repository methods the UI and CLI use, so
| Imported alarm references missing on-trigger script | `BundleImportAlarmScriptUnresolved` (warning; alarm FK left null) |
| Imported template's composition references missing target template | `BundleImportCompositionUnresolved` (warning; composition row not written) |
**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The existing Configuration Audit Log Viewer gains a **Bundle Import** filter that surfaces all rows for a given import. The `BundleImported` summary row links to the filtered view.
**Correlation:** every per-entity row written during an import carries a new optional `BundleImportId` column (the GUID of the parent `BundleImported` summary row). The repository layer (`CentralUiRepository.QueryConfigurationAuditAsync`) already accepts a `BundleImportId` filter parameter. The Configuration Audit Log Viewer UI surface — a "Bundle Import" filter dropdown and a hyperlink on the `BundleImported` summary row that pre-populates that filter — is a deferred UI follow-up (tracked under Transport-012 in the code review backlog). Until then, an operator can drive the same filter via the CLI `audit query --bundle-import-id <guid>`.
**Schema change:** one EF migration adds:
@@ -201,10 +201,23 @@ public class DatabaseGateway : IDatabaseGateway
return true;
}
private static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
// ExternalSystemGateway-020: a JSON number that does not fit in Int64 must
// prefer decimal over double — a script's decimal SQL parameter is
// serialised as JSON without a type tag, and downcasting it to double on
// the cached-write retry path silently loses precision (e.g.
// 1234567890.1234567890 -> 1234567890.1234567 as a binary float). Probe
// long first (whole-number fast path), then decimal (preserves authored
// precision for typical money/measurement values), and only fall through
// to double for genuinely out-of-decimal-range values (very large
// scientific-notation floats).
internal static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
{
JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
JsonValueKind.Number => element.TryGetInt64(out var l)
? l
: element.TryGetDecimal(out var dec)
? dec
: element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null or JsonValueKind.Undefined => DBNull.Value,
@@ -139,10 +139,7 @@ public class DiffService
/// Compares two <see cref="ConnectionConfig"/> instances for equality across
/// the fields that travel in the deployment package: protocol, primary and
/// backup configuration JSON, and failover retry count. Used by callers that
/// need to detect connection-endpoint drift; the public diff shape only
/// exposes attribute / alarm / script changes today (see TemplateEngine-018
/// for the diff-shape extension that surfaces added / removed / changed
/// connections in the UI).
/// need to detect connection-endpoint drift.
/// </summary>
/// <param name="a">First connection configuration.</param>
/// <param name="b">Second connection configuration.</param>
@@ -157,4 +154,52 @@ public class DiffService
a.BackupConfigurationJson == b.BackupConfigurationJson &&
a.FailoverRetryCount == b.FailoverRetryCount;
}
/// <summary>
/// TemplateEngine-018: produces a per-connection diff between two flattened
/// configurations, emitting Added / Removed / Changed entries keyed by the
/// connection name. Mirrors the existing <see cref="ComputeEntityDiff{T}"/>
/// shape used for attributes / alarms / scripts but is exposed as a separate
/// method because <see cref="ConfigurationDiff"/> in
/// <c>ScadaLink.Commons</c> does not yet carry a <c>ConnectionChanges</c>
/// slot — the public diff record will be extended in a paired Commons change
/// (this file is the only one in this fix's scope). A null
/// <c>Connections</c> dictionary on either side is treated as the empty map.
/// </summary>
/// <param name="oldConfig">The previously deployed configuration, or null
/// for first-time deployment.</param>
/// <param name="newConfig">The current flattened configuration.</param>
/// <returns>Added / Removed / Changed entries keyed by connection name,
/// sorted ordinally by <see cref="DiffEntry{T}.CanonicalName"/>.</returns>
public IReadOnlyList<DiffEntry<ConnectionConfig>> ComputeConnectionsDiff(
FlattenedConfiguration? oldConfig,
FlattenedConfiguration newConfig)
{
ArgumentNullException.ThrowIfNull(newConfig);
// Project both sides to (name, config) pairs. A null Connections
// dictionary models the no-connections case (first deploy, or all
// bindings cleared) — treat it as empty so the diff still reports the
// counterpart side's entries as Added / Removed rather than throwing.
var oldPairs = (oldConfig?.Connections ?? new Dictionary<string, ConnectionConfig>())
.Select(kvp => (kvp.Key, kvp.Value))
.ToList();
var newPairs = (newConfig.Connections ?? new Dictionary<string, ConnectionConfig>())
.Select(kvp => (kvp.Key, kvp.Value))
.ToList();
return ComputeEntityDiff(
oldPairs,
newPairs,
pair => pair.Key,
(a, b) => ConnectionsEqual(a.Value, b.Value))
.Select(entry => new DiffEntry<ConnectionConfig>
{
CanonicalName = entry.CanonicalName,
ChangeType = entry.ChangeType,
OldValue = entry.OldValue.Value,
NewValue = entry.NewValue.Value,
})
.ToList();
}
}
@@ -67,8 +67,14 @@ public class SharedScriptService
ReturnDefinition = returnDefinition
};
// TemplateEngine-020: save the entity first so EF Core populates the
// auto-generated key, then write the audit row with the real
// script.Id, then save the audit row. The pre-fix order logged
// EntityId = "0" because the audit row was queued before
// SaveChangesAsync ran.
await _repository.AddSharedScriptAsync(script, cancellationToken);
await _auditService.LogAsync(user, "Create", "SharedScript", "0", name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "SharedScript", script.Id.ToString(), name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<SharedScript>.Success(script);
@@ -103,6 +103,16 @@ public static class TemplateResolver
/// <summary>
/// Gets the inheritance chain from root ancestor to the specified template.
/// </summary>
/// <remarks>
/// TemplateEngine-019: the parent walk uses the <see cref="int?"/>
/// <see cref="Template.ParentTemplateId"/> directly — only a missing
/// (<c>null</c>) value means "no parent". The legacy <c>0</c>-as-no-parent
/// sentinel that was removed from <c>CycleDetector</c> in the
/// TemplateEngine-013 fix had silently truncated chains whenever a real
/// template id of 0 appeared (e.g. import-staging / not-yet-saved rows);
/// the duplicate-tolerant <c>BuildLookup</c> upstream means an Id of 0 is
/// a valid node here and must walk the chain like any other.
/// </remarks>
/// <param name="templateId">The template ID to build the chain for.</param>
/// <param name="lookup">A dictionary mapping template IDs to templates.</param>
/// <returns>A read-only list of templates from root to the specified template.</returns>
@@ -111,16 +121,16 @@ public static class TemplateResolver
IReadOnlyDictionary<int, Template> lookup)
{
var chain = new List<Template>();
var currentId = templateId;
int? currentId = templateId;
var visited = new HashSet<int>();
while (currentId != 0 && lookup.TryGetValue(currentId, out var current))
while (currentId.HasValue && lookup.TryGetValue(currentId.Value, out var current))
{
if (!visited.Add(currentId))
if (!visited.Add(currentId.Value))
break; // Safety: cycle detected
chain.Add(current);
currentId = current.ParentTemplateId ?? 0;
currentId = current.ParentTemplateId;
}
chain.Reverse(); // Root first
@@ -73,8 +73,15 @@ public class TemplateService
// collisions are enforced on every member-mutating call (AddAttribute,
// AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.
// TemplateEngine-020: save the entity first so EF Core populates the
// auto-generated key, then write the audit row with the real
// template.Id, then save the audit row. The pre-fix order logged
// EntityId = "0" because the audit row was queued before
// SaveChangesAsync ran — every Create audit entry lost the link back
// to the row it described.
await _repository.AddTemplateAsync(template, cancellationToken);
await _auditService.LogAsync(user, "Create", "Template", "0", name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "Template", template.Id.ToString(), name, template, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<Template>.Success(template);
@@ -277,8 +284,11 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real attribute Id rather than a literal "0".
await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAttribute", "0", attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAttribute>.Success(attribute);
@@ -441,8 +451,11 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real alarm Id rather than a literal "0".
await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAlarm", "0", alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateAlarm>.Success(alarm);
@@ -599,8 +612,11 @@ public class TemplateService
if (collisions.Count > 0)
return Result<TemplateScript>.Failure(string.Join(" ", collisions));
// TemplateEngine-020: save-then-audit so the audit row carries the
// real script Id rather than a literal "0".
await _repository.AddTemplateScriptAsync(script, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateScript", "0", script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateScript>.Success(script);
@@ -787,8 +803,12 @@ public class TemplateService
// No global name pre-check: derived templates store their contained
// (slot) name, which need only be unique within the owner — and that is
// already enforced above and by the (TemplateId, InstanceName) index.
// TemplateEngine-020: CreateCascadedCompositionAsync already saves the
// composition row, so composition.Id is populated by the time control
// returns here — the audit row therefore carries the real Id instead
// of the pre-fix literal "0".
var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", "0", instanceName, composition, cancellationToken);
await _auditService.LogAsync(user, "Create", "TemplateComposition", composition.Id.ToString(), instanceName, composition, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TemplateComposition>.Success(composition);
@@ -1,5 +1,6 @@
using System.Data;
using System.Data.Common;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.ExternalSystems;
@@ -232,6 +233,66 @@ public class DatabaseGatewayTests
Assert.False(delivered); // permanent — the S&F engine parks the message
}
// ── ExternalSystemGateway-020: decimal SQL parameter precision survives JsonElement round-trip ──
[Fact]
public void JsonElementToParameterValue_DecimalShapedNumber_PreservesPrecisionViaDecimal()
{
// A script's decimal SQL parameter is serialised as a bare JSON number
// (System.Text.Json has no decimal type tag), then on the cached-write
// retry path the buffered payload is re-deserialised into a
// JsonElement and the gateway must materialise a CLR value for the
// parameter. Pre-020 it called GetDouble() for any non-Int64 number,
// which silently downcast every decimal to a binary float and lost
// precision (1234567890.1234567890 -> 1234567890.1234567 as double).
// The 020 fix prefers decimal — round-tripping must preserve every
// digit of an authoring-time decimal value.
const string authoredJson = "1234567890.1234567890";
// Round-trip through JsonElement, mirroring the buffered-payload path.
using var document = JsonDocument.Parse(authoredJson);
var element = document.RootElement;
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
// The materialised value must be the original decimal, not a double.
// Asserting on the type alone is enough to fail pre-020 (which
// produced a System.Double); the value assertion locks in the
// precision invariant.
var asDecimal = Assert.IsType<decimal>(materialised);
Assert.Equal(1234567890.1234567890m, asDecimal);
}
[Fact]
public void JsonElementToParameterValue_WholeNumber_FastPathReturnsLong()
{
// Whole numbers must keep the existing Int64 fast path — the 020 fix
// is "long first, then decimal, then double", not "decimal first".
using var document = JsonDocument.Parse("42");
var element = document.RootElement;
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
Assert.IsType<long>(materialised);
Assert.Equal(42L, materialised);
}
[Fact]
public void JsonElementToParameterValue_OutOfDecimalRangeNumber_FallsThroughToDouble()
{
// A genuinely out-of-decimal-range value (e.g. very large scientific
// notation) must still fall through to double rather than throw — the
// decimal probe is a precision-preserving preference, not a hard
// requirement.
using var document = JsonDocument.Parse("1e40");
var element = document.RootElement;
var materialised = DatabaseGateway.JsonElementToParameterValue(element);
Assert.IsType<double>(materialised);
Assert.Equal(1e40, (double)materialised);
}
// ── ExternalSystemGateway-010: SqlConnection must not leak when OpenAsync fails ──
[Fact]
@@ -251,4 +251,122 @@ public class DiffServiceTests
Assert.False(DiffService.ConnectionsEqual(a, b));
}
// ── TemplateEngine-018: ComputeConnectionsDiff produces Added/Removed/Changed entries ──
[Fact]
public void ComputeConnectionsDiff_NewBindingAdded_ReportedAsAdded()
{
// First-time binding (or instance gains its first data-sourced
// attribute) — old config has no Connections map, new config does.
// The pre-018 diff shape silently dropped this so operators saw
// "no changes" when the deployment package was structurally larger.
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host\"}",
FailoverRetryCount = 3,
}
}
};
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Added, diff[0].ChangeType);
Assert.Null(diff[0].OldValue);
Assert.NotNull(diff[0].NewValue);
Assert.Equal("OpcUa", diff[0].NewValue!.Protocol);
}
[Fact]
public void ComputeConnectionsDiff_BindingCleared_ReportedAsRemoved()
{
// Last data-sourced attribute removed — old config carried a
// connection, new config does not.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}" }
}
};
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1" };
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Removed, diff[0].ChangeType);
Assert.NotNull(diff[0].OldValue);
Assert.Null(diff[0].NewValue);
}
[Fact]
public void ComputeConnectionsDiff_EndpointEdit_ReportedAsChanged()
{
// A connection-endpoint edit must surface as a Changed diff entry —
// the deployment package will ship a different ConnectionConfig and
// the operator-facing diff view must say so.
var oldConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
FailoverRetryCount = 3,
}
}
};
var newConfig = new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig
{
Protocol = "OpcUa",
ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}",
FailoverRetryCount = 3,
}
}
};
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Single(diff);
Assert.Equal("plc1", diff[0].CanonicalName);
Assert.Equal(DiffChangeType.Changed, diff[0].ChangeType);
Assert.Contains("host-a", diff[0].OldValue!.ConfigurationJson);
Assert.Contains("host-b", diff[0].NewValue!.ConfigurationJson);
}
[Fact]
public void ComputeConnectionsDiff_IdenticalConnections_NoEntries()
{
// Sanity check: an unchanged connection produces no diff entry, so
// ComputeConnectionsDiff stays quiet when nothing relevant has
// changed.
var connections = new Dictionary<string, ConnectionConfig>
{
["plc1"] = new ConnectionConfig { Protocol = "OpcUa", ConfigurationJson = "{}" }
};
var oldConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Connections = connections };
var newConfig = new FlattenedConfiguration { InstanceUniqueName = "Instance1", Connections = connections };
var diff = _sut.ComputeConnectionsDiff(oldConfig, newConfig);
Assert.Empty(diff);
}
}
@@ -156,4 +156,48 @@ public class SharedScriptServiceTests
{
Assert.Null(SharedScriptService.ValidateSyntax(code));
}
// --- TemplateEngine-020 regression: audit row carries the real script Id ---
[Fact]
public async Task CreateSharedScript_AuditRowCarriesRealScriptIdNotLiteralZero()
{
// Pre-020: AddSharedScriptAsync → LogAsync("0", ...) → SaveChangesAsync.
// The audit row was queued with EntityId = "0" because EF Core had
// not yet populated the auto-generated key. Post-020: save first,
// then log with the real Id, then save the audit row.
_repoMock.Setup(r => r.GetSharedScriptByNameAsync("Helpers", It.IsAny<CancellationToken>()))
.ReturnsAsync((SharedScript?)null);
SharedScript? added = null;
_repoMock.Setup(r => r.AddSharedScriptAsync(It.IsAny<SharedScript>(), It.IsAny<CancellationToken>()))
.Callback<SharedScript, CancellationToken>((s, _) => added = s)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 314;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"SharedScript",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var result = await _service.CreateSharedScriptAsync(
"Helpers", "public static int Add(int a, int b) { return a + b; }", null, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("314", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
}
@@ -174,4 +174,80 @@ public class TemplateResolverTests
Assert.Equal("P", chain[1].Name);
Assert.Equal("C", chain[2].Name);
}
// ── TemplateEngine-019: a real Id of 0 must walk the inheritance chain ──
[Fact]
public void BuildInheritanceChain_RealIdZero_IsTreatedAsParentReferenceNotAsNoParent()
{
// TemplateEngine-013 removed the 0-as-no-parent sentinel from
// CycleDetector; the same fix had not propagated into the resolver,
// so seeding BuildInheritanceChain with templateId == 0 returned an
// empty chain even when a template with Id 0 existed (e.g. an
// import-staging row, or any in-memory test setup). Post-019: only a
// null ParentTemplateId means "no parent", and an Id of 0 walks the
// chain like any other node.
var orphaned = new Template("OrphanedId0") { Id = 0 };
orphaned.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 1,
TemplateId = 0,
DataType = DataType.Float,
});
var lookup = new Dictionary<int, Template> { [0] = orphaned };
var chain = TemplateResolver.BuildInheritanceChain(0, lookup);
// Pre-019: while (currentId != 0 && ...) was false on the first
// iteration, so the chain was empty and the orphaned template's
// members were silently dropped from every flatten/resolve through
// it. Post-019: the orphan is the chain.
Assert.Single(chain);
Assert.Equal("OrphanedId0", chain[0].Name);
}
[Fact]
public void BuildInheritanceChain_ParentChainThroughIdZero_DoesNotTruncateChainAtZero()
{
// A template whose real parent has Id 0 must include the Id 0 parent
// in the chain. Pre-019: `current.ParentTemplateId ?? 0` paired with
// `currentId != 0` exited the loop as soon as the walk reached a real
// Id of 0 — silently truncating the inheritance contribution from the
// root template.
var parent = new Template("ParentWithIdZero") { Id = 0 };
parent.Attributes.Add(new TemplateAttribute("RootAttr")
{
Id = 1,
TemplateId = 0,
DataType = DataType.String,
});
var child = new Template("Child") { Id = 5, ParentTemplateId = 0 };
var lookup = new Dictionary<int, Template> { [0] = parent, [5] = child };
var chain = TemplateResolver.BuildInheritanceChain(5, lookup);
Assert.Equal(2, chain.Count);
Assert.Equal("ParentWithIdZero", chain[0].Name); // root first
Assert.Equal("Child", chain[1].Name);
}
[Fact]
public void ResolveAllMembers_TemplateWithRealIdZero_StillResolvesItsMembers()
{
// End-to-end: ResolveAllMembers piggybacks on BuildInheritanceChain,
// so the chain truncation regression also dropped every member of an
// Id-0 template from the resolved-member set. Lock this in too.
var orphan = new Template("OrphanedId0") { Id = 0 };
orphan.Attributes.Add(new TemplateAttribute("Speed")
{
Id = 1,
TemplateId = 0,
DataType = DataType.Float,
});
var members = TemplateResolver.ResolveAllMembers(0, new List<Template> { orphan });
Assert.Single(members);
Assert.Equal("Speed", members[0].CanonicalName);
}
}
@@ -34,7 +34,11 @@ public class TemplateServiceTests
Assert.True(result.IsSuccess);
Assert.Equal("Pump", result.Value.Name);
_repoMock.Verify(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
// TemplateEngine-020: SaveChangesAsync runs twice — once after the
// AddTemplateAsync to populate the auto-generated key (so the audit
// row can carry the real Id), and once after LogAsync to persist the
// audit row itself.
_repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
_auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
}
@@ -1195,4 +1199,177 @@ public class TemplateServiceTests
Assert.Contains("locked-in-derived", result.Error);
Assert.True(existing.LockedInDerived); // unchanged
}
// ========================================================================
// TemplateEngine-020: Create* audit rows must carry the real entity Id,
// not the literal "0" that EF Core leaves on the entity until
// SaveChangesAsync populates the auto-generated key.
// ========================================================================
[Fact]
public async Task CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero()
{
// Simulate EF Core's identity-key population: the repository's
// AddTemplateAsync stamps Id during SaveChangesAsync. The pre-020
// order (Add → LogAsync → Save) queued the audit row before the Id
// existed, so every Create audit entry persisted with EntityId = "0"
// and the audit trail lost its link back to the created template.
// Post-020: save first, then log with the real Id, then save the
// audit row.
Template? added = null;
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
.Callback<Template, CancellationToken>((t, _) => added = t)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
// First SaveChangesAsync after Add: assign the identity key.
if (added != null && added.Id == 0) added.Id = 42;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"Template",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var result = await _service.CreateTemplateAsync("Pump", null, null, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("42", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
[Fact]
public async Task AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero()
{
var template = new Template("Pump") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAttribute? added = null;
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added = a)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 77;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"TemplateAttribute",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
var result = await _service.AddAttributeAsync(1, attr, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("77", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
[Fact]
public async Task AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero()
{
var template = new Template("Pump") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAlarm? added = null;
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAlarm, CancellationToken>((a, _) => added = a)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 99;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"TemplateAlarm",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var alarm = new TemplateAlarm("HighTemp")
{
PriorityLevel = 500,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = """{"Max": 100}"""
};
var result = await _service.AddAlarmAsync(1, alarm, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("99", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
[Fact]
public async Task AddScript_AuditRowCarriesRealScriptIdNotLiteralZero()
{
var template = new Template("Pump") { Id = 1 };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateScript? added = null;
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
.Callback<TemplateScript, CancellationToken>((s, _) => added = s)
.Returns(Task.CompletedTask);
_repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
.Callback<CancellationToken>(_ =>
{
if (added != null && added.Id == 0) added.Id = 123;
})
.ReturnsAsync(1);
string? auditedEntityId = null;
_auditMock.Setup(a => a.LogAsync(
It.IsAny<string>(),
"Create",
"TemplateScript",
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<object?>(),
It.IsAny<CancellationToken>()))
.Callback<string, string, string, string, string, object?, CancellationToken>(
(_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
.Returns(Task.CompletedTask);
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
var result = await _service.AddScriptAsync(1, script, "admin");
Assert.True(result.IsSuccess);
Assert.Equal("123", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
}