fix(transport): Overwrite resolution now syncs child collections (2 findings)

Transport-001: template Overwrite now diff-and-merges the bundle's
Attributes / Alarms / Scripts onto the target template via three private
helpers (SyncTemplateAttributesAsync / SyncTemplateAlarmsAsync /
SyncTemplateScriptsAsync). Each helper emits one audit row per detected
add / update / delete and feeds the post-merge state into the existing
ResolveAlarmScriptLinks and ResolveCompositionEdges passes.

Transport-002: external-system Overwrite now syncs the Methods collection
via a parallel SyncExternalSystemMethodsAsync helper mirroring the T-001
shape, with ExternalSystemMethodAdded / Updated / Deleted audit rows.

Both fixes are covered by new integration tests in BundleImporterApplyTests.
README regenerated — open findings dropped from 146 to 136; all 10 open
High findings are now closed (0 Critical, 0 High, 46 Medium, 90 Low
remaining).
This commit is contained in:
Joseph Doherty
2026-05-28 05:54:03 -04:00
parent ac96b83b08
commit e3ca9af1be
4 changed files with 758 additions and 35 deletions
+13 -24
View File
@@ -40,10 +40,10 @@ module file and counted in **Total**.
| Severity | Open findings |
|----------|---------------|
| Critical | 0 |
| High | 10 |
| High | 0 |
| Medium | 46 |
| Low | 90 |
| **Total** | **146** |
| **Total** | **136** |
## Module Status
@@ -54,24 +54,24 @@ module file and counted in **Total**.
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 33 |
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 14 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/6 | 9 | 23 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/1/5 | 7 | 22 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/5 | 6 | 22 |
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/4/5 | 9 | 24 |
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/1/5 | 7 | 24 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/2/3 | 6 | 23 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/5 | 6 | 24 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/3 | 5 | 23 |
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 23 |
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/5 | 7 | 22 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/3/4 | 8 | 25 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/4 | 7 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/2 | 4 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/3 | 5 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/2/3 | 6 | 25 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/3 | 5 | 25 |
| [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 21 |
| [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 6 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/2/6 | 9 | 23 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/6 | 8 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/3 | 5 | 26 |
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/3/3 | 7 | 24 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/1/4/1 | 6 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/2/2/4 | 8 | 12 |
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/3 | 6 | 24 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/4/1 | 5 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/4 | 6 | 12 |
## Pending Findings
@@ -84,20 +84,9 @@ description, location, recommendation — lives in the module's `findings.md`.
_None open._
### High (10)
### High (0)
| ID | Module | Title |
|----|--------|-------|
| Communication-016 | [Communication](Communication/findings.md) | `HandleConnectionStateChanged` is dead code — the documented disconnect-cleanup workflow never fires |
| DeploymentManager-018 | [DeploymentManager](DeploymentManager/findings.md) | Reconciliation force-sets `Enabled`, overwriting an intentional `Disabled` after central failover |
| ExternalSystemGateway-018 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `DeliverBufferedAsync` lets `JsonException` propagate, turning a corrupt buffered row into a permanent retry-forever poison message |
| InboundAPI-022 | [InboundAPI](InboundAPI/findings.md) | `IActiveNodeGate` has no production registration in Host — standby-node gating is silently disabled in production |
| NotificationService-019 | [NotificationService](NotificationService/findings.md) | `NotificationDeliveryService` and `INotificationDeliveryService` are orphaned by the central-only redesign |
| SiteEventLogging-016 | [SiteEventLogging](SiteEventLogging/findings.md) | `From`/`To` filters compare non-normalised ISO 8601 strings against UTC-stored timestamps |
| StoreAndForward-018 | [StoreAndForward](StoreAndForward/findings.md) | Notification corrupt-payload parks the buffered message, contradicting the "notifications do not park" design invariant |
| TemplateEngine-017 | [TemplateEngine](TemplateEngine/findings.md) | Revision hash and diff both ignore `Description` and `Connections`, defeating staleness detection for real deployment changes |
| Transport-001 | [Transport](Transport/findings.md) | Template Overwrite never syncs attributes / alarms / scripts |
| Transport-002 | [Transport](Transport/findings.md) | ExternalSystem Overwrite never syncs methods |
_None open._
### Medium (46)
+25 -10
View File
@@ -53,9 +53,23 @@ entry-count / per-entry decompression cap).
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:844-851` |
**Resolution** — Extended `ApplyTemplatesAsync`'s Overwrite branch with three
new private diff-and-merge helpers (`SyncTemplateAttributesAsync`,
`SyncTemplateAlarmsAsync`, `SyncTemplateScriptsAsync`) that compare the bundle
DTO's children against the tracked existing template's collections by name and
stage add / update / delete via the audited repository methods. Each detected
change emits one of the per-field audit events the design doc enumerates
(`TemplateAttributeAdded` / `TemplateAttributeUpdated` /
`TemplateAttributeDeleted` and the alarm / script analogues); the existing
`ResolveAlarmScriptLinksAsync` and `ResolveCompositionEdgesAsync` passes rewire
the alarm→script FK and composition graph against the post-merge state with no
changes — Overwrite-on-alarms resets `OnTriggerScriptId` so Pass A is
authoritative. Regression test:
`BundleImporterApplyTests.ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle`.
**Description**
The `ResolutionAction.Overwrite` branch in `ApplyTemplatesAsync` only writes
@@ -80,19 +94,24 @@ composition rewire passes against the post-merge state. Emit the per-field audit
rows the design doc enumerates. Add an integration test that overwrites a
template whose Scripts / Attributes / Alarms differ.
**Resolution**
_Unresolved._
### Transport-002 — ExternalSystem Overwrite never syncs methods
| | |
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:1213-1221` |
**Resolution** — Added a private `SyncExternalSystemMethodsAsync` helper to
`BundleImporter` modeled on the T-001 `SyncTemplate*Async` helpers (dictionary-
by-name diff, repo Add / Update / Delete, scalar-field-compare gating, one
audit row per change). The `ApplyExternalSystemsAsync` Overwrite branch now
calls it after the parent scalar update; the helper emits
`ExternalSystemMethodAdded` / `ExternalSystemMethodUpdated` /
`ExternalSystemMethodDeleted` per change. Covered by
`ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle`.
**Description**
`ApplyExternalSystemsAsync` Overwrite path writes `EndpointUrl`, `AuthType`, and
@@ -111,10 +130,6 @@ diff classification in `ArtifactDiff.CompareExternalSystem`) and emit the
per-method audit rows. Add a test that overwrites an external system whose
methods differ.
**Resolution**
_Unresolved._
### Transport-003 — Unlock lockout is enforced only client-side; server session is never marked Locked
| | |
@@ -975,6 +975,17 @@ public sealed class BundleImporter : IBundleImporter
await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name,
new { ex.Name, ex.Description, ex.FolderId }, ct).ConfigureAwait(false);
// T-001: Overwrite must also synchronise child collections —
// attributes / alarms / scripts diverging between the bundle
// and the target must round-trip. Composition rewire is
// handled by ResolveCompositionEdgesAsync after the global
// flush; alarm→script FKs are rewired by
// ResolveAlarmScriptLinksAsync. The helpers below stage the
// child diffs (add / update / delete) onto the tracked
// entity and emit one audit row per detected change.
await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
@@ -1055,6 +1066,329 @@ public sealed class BundleImporter : IBundleImporter
return t;
}
/// <summary>
/// T-001 — Overwrite child sync (attributes). Diffs the DTO's
/// <c>Attributes</c> against the existing template's attribute collection
/// by name and stages add / update / delete on the tracked entity. Emits
/// one <c>TemplateAttributeAdded</c> / <c>TemplateAttributeUpdated</c> /
/// <c>TemplateAttributeDeleted</c> audit row per detected change — the
/// per-field rows the design doc's Configuration Audit Trail table
/// enumerates for the "Template overwritten" action.
/// <para>
/// Update detection compares every scalar field (Value, DataType,
/// IsLocked, Description, DataSourceReference) — no field change → no
/// audit row, so an idempotent overwrite produces no noise.
/// </para>
/// </summary>
private async Task SyncTemplateAttributesAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
var dtoByName = dto.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
// Deletes — attributes present on the target but not in the bundle.
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateAttributeAsync(existing.Id, ct).ConfigureAwait(false);
ex.Attributes.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateAttributeDeleted",
"TemplateAttribute",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, AttributeName = existing.Name },
ct).ConfigureAwait(false);
}
// Adds + Updates.
foreach (var attrDto in dto.Attributes)
{
if (existingByName.TryGetValue(attrDto.Name, out var current))
{
// Update only if any field actually changed.
bool changed =
!string.Equals(current.Value, attrDto.Value, StringComparison.Ordinal) ||
current.DataType != attrDto.DataType ||
current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal);
if (!changed) continue;
current.Value = attrDto.Value;
current.DataType = attrDto.DataType;
current.IsLocked = attrDto.IsLocked;
current.Description = attrDto.Description;
current.DataSourceReference = attrDto.DataSourceReference;
await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateAttributeUpdated",
"TemplateAttribute",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
AttributeName = current.Name,
current.Value,
current.DataType,
current.IsLocked,
current.Description,
current.DataSourceReference,
},
ct).ConfigureAwait(false);
}
else
{
var newAttr = new TemplateAttribute(attrDto.Name)
{
Value = attrDto.Value,
DataType = attrDto.DataType,
IsLocked = attrDto.IsLocked,
Description = attrDto.Description,
DataSourceReference = attrDto.DataSourceReference,
};
ex.Attributes.Add(newAttr);
await _auditService.LogAsync(
user,
"TemplateAttributeAdded",
"TemplateAttribute",
"0",
$"{ex.Name}.{newAttr.Name}",
new
{
TemplateName = ex.Name,
AttributeName = newAttr.Name,
newAttr.Value,
newAttr.DataType,
newAttr.IsLocked,
newAttr.Description,
newAttr.DataSourceReference,
},
ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// T-001 — Overwrite child sync (alarms). Mirrors
/// <see cref="SyncTemplateAttributesAsync"/> for the alarm collection.
/// Updated / added alarms have their <c>OnTriggerScriptId</c> cleared so
/// the post-flush <see cref="ResolveAlarmScriptLinksAsync"/> pass re-binds
/// the FK from the DTO's <c>OnTriggerScriptName</c> against the synced
/// script collection. Audit rows: <c>TemplateAlarmAdded</c> /
/// <c>TemplateAlarmUpdated</c> / <c>TemplateAlarmDeleted</c>.
/// </summary>
private async Task SyncTemplateAlarmsAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateAlarmAsync(existing.Id, ct).ConfigureAwait(false);
ex.Alarms.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateAlarmDeleted",
"TemplateAlarm",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, AlarmName = existing.Name },
ct).ConfigureAwait(false);
}
foreach (var alarmDto in dto.Alarms)
{
if (existingByName.TryGetValue(alarmDto.Name, out var current))
{
bool changed =
!string.Equals(current.Description, alarmDto.Description, StringComparison.Ordinal) ||
current.PriorityLevel != alarmDto.PriorityLevel ||
current.TriggerType != alarmDto.TriggerType ||
!string.Equals(current.TriggerConfiguration, alarmDto.TriggerConfiguration, StringComparison.Ordinal) ||
current.IsLocked != alarmDto.IsLocked;
if (!changed)
{
// Always reset the script FK on Overwrite so the post-flush
// resolve pass owns the binding (the DTO's script name is
// the authoritative reference); leaving a stale FK would
// silently survive Overwrite when the user expected the
// bundle to be the source of truth.
if ((current.OnTriggerScriptId is not null) ||
!string.IsNullOrEmpty(alarmDto.OnTriggerScriptName))
{
current.OnTriggerScriptId = null;
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
}
continue;
}
current.Description = alarmDto.Description;
current.PriorityLevel = alarmDto.PriorityLevel;
current.TriggerType = alarmDto.TriggerType;
current.TriggerConfiguration = alarmDto.TriggerConfiguration;
current.IsLocked = alarmDto.IsLocked;
current.OnTriggerScriptId = null; // re-resolved post-flush.
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateAlarmUpdated",
"TemplateAlarm",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
AlarmName = current.Name,
current.Description,
current.PriorityLevel,
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
}
else
{
var newAlarm = new TemplateAlarm(alarmDto.Name)
{
Description = alarmDto.Description,
PriorityLevel = alarmDto.PriorityLevel,
TriggerType = alarmDto.TriggerType,
TriggerConfiguration = alarmDto.TriggerConfiguration,
IsLocked = alarmDto.IsLocked,
};
ex.Alarms.Add(newAlarm);
await _auditService.LogAsync(
user,
"TemplateAlarmAdded",
"TemplateAlarm",
"0",
$"{ex.Name}.{newAlarm.Name}",
new
{
TemplateName = ex.Name,
AlarmName = newAlarm.Name,
newAlarm.Description,
newAlarm.PriorityLevel,
newAlarm.TriggerType,
newAlarm.TriggerConfiguration,
newAlarm.IsLocked,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// T-001 — Overwrite child sync (scripts). Mirrors
/// <see cref="SyncTemplateAttributesAsync"/> for the script collection.
/// Audit rows: <c>TemplateScriptAdded</c> / <c>TemplateScriptUpdated</c> /
/// <c>TemplateScriptDeleted</c>.
/// </summary>
private async Task SyncTemplateScriptsAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
var dtoByName = dto.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateScriptAsync(existing.Id, ct).ConfigureAwait(false);
ex.Scripts.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateScriptDeleted",
"TemplateScript",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, ScriptName = existing.Name },
ct).ConfigureAwait(false);
}
foreach (var scriptDto in dto.Scripts)
{
if (existingByName.TryGetValue(scriptDto.Name, out var current))
{
bool changed =
!string.Equals(current.Code, scriptDto.Code, StringComparison.Ordinal) ||
!string.Equals(current.TriggerType, scriptDto.TriggerType, StringComparison.Ordinal) ||
!string.Equals(current.TriggerConfiguration, scriptDto.TriggerConfiguration, StringComparison.Ordinal) ||
!string.Equals(current.ParameterDefinitions, scriptDto.ParameterDefinitions, StringComparison.Ordinal) ||
!string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) ||
current.IsLocked != scriptDto.IsLocked;
if (!changed) continue;
current.Code = scriptDto.Code;
current.TriggerType = scriptDto.TriggerType;
current.TriggerConfiguration = scriptDto.TriggerConfiguration;
current.ParameterDefinitions = scriptDto.ParameterDefinitions;
current.ReturnDefinition = scriptDto.ReturnDefinition;
current.IsLocked = scriptDto.IsLocked;
await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateScriptUpdated",
"TemplateScript",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
ScriptName = current.Name,
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
},
ct).ConfigureAwait(false);
}
else
{
var newScript = new TemplateScript(scriptDto.Name, scriptDto.Code)
{
TriggerType = scriptDto.TriggerType,
TriggerConfiguration = scriptDto.TriggerConfiguration,
ParameterDefinitions = scriptDto.ParameterDefinitions,
ReturnDefinition = scriptDto.ReturnDefinition,
IsLocked = scriptDto.IsLocked,
};
ex.Scripts.Add(newScript);
await _auditService.LogAsync(
user,
"TemplateScriptAdded",
"TemplateScript",
"0",
$"{ex.Name}.{newScript.Name}",
new
{
TemplateName = ex.Name,
ScriptName = newScript.Name,
newScript.TriggerType,
newScript.TriggerConfiguration,
newScript.IsLocked,
},
ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
@@ -1345,6 +1679,12 @@ public sealed class BundleImporter : IBundleImporter
await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
// T-002: Overwrite must also synchronise the Methods child
// collection — added / removed / modified methods on the
// bundle DTO must round-trip. Mirrors the T-001 template
// child-sync helpers (attributes / alarms / scripts): each
// helper emits one audit row per detected change.
await SyncExternalSystemMethodsAsync(existing, dto, user, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
@@ -1371,6 +1711,114 @@ public sealed class BundleImporter : IBundleImporter
return sys;
}
/// <summary>
/// T-002 — Overwrite child sync (ExternalSystem methods). Mirrors the
/// T-001 <c>SyncTemplate*Async</c> helpers: name-keyed diff between the
/// bundle DTO and the persisted children, then add / update / delete via
/// the repository with one audit row per detected change. Methods are
/// NOT a navigation on <see cref="ExternalSystemDefinition"/> (the FK
/// runs from <see cref="ExternalSystemMethod.ExternalSystemDefinitionId"/>
/// to the parent) so the helper drives the repo directly rather than
/// mutating a tracked collection like the template helpers do.
/// <para>
/// Audit rows: <c>ExternalSystemMethodAdded</c> /
/// <c>ExternalSystemMethodUpdated</c> / <c>ExternalSystemMethodDeleted</c>.
/// Idempotent: scalar-field comparison gates the Update audit row, so an
/// Overwrite against an already-matching method produces no noise.
/// </para>
/// </summary>
private async Task SyncExternalSystemMethodsAsync(
ExternalSystemDefinition ex,
ExternalSystemDto dto,
string user,
CancellationToken ct)
{
var existingMethods = await _externalRepo
.GetMethodsByExternalSystemIdAsync(ex.Id, ct)
.ConfigureAwait(false);
var existingByName = existingMethods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
var dtoByName = dto.Methods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
// Deletes — methods present on the target but not in the bundle.
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _externalRepo.DeleteExternalSystemMethodAsync(existing.Id, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodDeleted",
"ExternalSystemMethod",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { ExternalSystemName = ex.Name, MethodName = existing.Name },
ct).ConfigureAwait(false);
}
// Adds + Updates.
foreach (var methodDto in dto.Methods)
{
if (existingByName.TryGetValue(methodDto.Name, out var current))
{
// Update only if any field actually changed — mirrors the
// ArtifactDiff.ExternalSystemMethodsEqual comparator.
bool changed =
!string.Equals(current.HttpMethod, methodDto.HttpMethod, StringComparison.Ordinal) ||
!string.Equals(current.Path, methodDto.Path, StringComparison.Ordinal) ||
!string.Equals(current.ParameterDefinitions, methodDto.ParameterDefinitions, StringComparison.Ordinal) ||
!string.Equals(current.ReturnDefinition, methodDto.ReturnDefinition, StringComparison.Ordinal);
if (!changed) continue;
current.HttpMethod = methodDto.HttpMethod;
current.Path = methodDto.Path;
current.ParameterDefinitions = methodDto.ParameterDefinitions;
current.ReturnDefinition = methodDto.ReturnDefinition;
await _externalRepo.UpdateExternalSystemMethodAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodUpdated",
"ExternalSystemMethod",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
ExternalSystemName = ex.Name,
MethodName = current.Name,
current.HttpMethod,
current.Path,
current.ParameterDefinitions,
current.ReturnDefinition,
},
ct).ConfigureAwait(false);
}
else
{
var newMethod = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
{
ExternalSystemDefinitionId = ex.Id,
ParameterDefinitions = methodDto.ParameterDefinitions,
ReturnDefinition = methodDto.ReturnDefinition,
};
await _externalRepo.AddExternalSystemMethodAsync(newMethod, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodAdded",
"ExternalSystemMethod",
"0",
$"{ex.Name}.{newMethod.Name}",
new
{
ExternalSystemName = ex.Name,
MethodName = newMethod.Name,
newMethod.HttpMethod,
newMethod.Path,
newMethod.ParameterDefinitions,
newMethod.ReturnDefinition,
},
ct).ConfigureAwait(false);
}
}
}
private async Task ApplyDatabaseConnectionsAsync(
IReadOnlyList<DatabaseConnectionDto> dtos,
Dictionary<(string, string), ImportResolution> map,
@@ -2,11 +2,13 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
@@ -86,10 +88,11 @@ public sealed class BundleImporterApplyTests : IDisposable
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
ExternalSystemIds: Array.Empty<int>(),
ExternalSystemIds: externalSystemIds,
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
@@ -476,4 +479,272 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Null(row.BundleImportId);
}
}
[Fact]
public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle()
{
// T-001 regression. The Overwrite branch used to write only Description
// / FolderId on the existing template; the bundle's Attributes / Alarms
// / Scripts collections were silently dropped on the floor. This test
// seeds a template with one shape, exports it, mutates the target to a
// divergent shape, then asserts that Overwrite restores every child
// collection AND emits per-field audit rows.
//
// Bundle shape (exported from "Pump"):
// Attributes: [SetPoint (Float, 50.0), Pressure (Float, 100.0)]
// Alarms: [HiAlarm (PriorityLevel=1)]
// Scripts: [Init (Code="return 1;")]
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var t = new Template("Pump") { Description = "from-bundle" };
t.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Float, Value = "50.0" });
t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "100.0" });
t.Alarms.Add(new TemplateAlarm("HiAlarm") { PriorityLevel = 1, TriggerType = AlarmTriggerType.ValueMatch });
t.Scripts.Add(new TemplateScript("Init", "return 1;"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target so every child collection diverges from the bundle.
// Attributes: SetPoint value mutated, Pressure DELETED, NewAttr ADDED
// Alarms: HiAlarm PriorityLevel mutated, ExtraAlarm ADDED
// Scripts: Init code mutated, ExtraScript ADDED
// Description also mutated so the scalar field still flips.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var pump = await ctx.Templates
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.SingleAsync(t => t.Name == "Pump");
pump.Description = "target-mutated";
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
setPoint.Value = "999.0"; // mutated
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
pump.Attributes.Remove(pressure);
ctx.TemplateAttributes.Remove(pressure);
pump.Attributes.Add(new TemplateAttribute("NewAttr")
{
DataType = DataType.String,
Value = "should-be-deleted-by-overwrite",
});
var hiAlarm = pump.Alarms.Single(a => a.Name == "HiAlarm");
hiAlarm.PriorityLevel = 99; // mutated
pump.Alarms.Add(new TemplateAlarm("ExtraAlarm")
{
PriorityLevel = 5,
TriggerType = AlarmTriggerType.RangeViolation,
});
var initScript = pump.Scripts.Single(s => s.Name == "Init");
initScript.Code = "return 999;"; // mutated
pump.Scripts.Add(new TemplateScript("ExtraScript", "return 0;"));
await ctx.SaveChangesAsync();
}
// Capture the audit baseline so we can scope assertions to rows
// emitted by THIS apply.
int beforeMaxAuditId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
}
// Act — apply Overwrite.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — children mirror the bundle.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var pump = await ctx.Templates
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.SingleAsync(t => t.Name == "Pump");
Assert.Equal("from-bundle", pump.Description);
// Attributes — exactly { SetPoint, Pressure }, values restored.
Assert.Equal(2, pump.Attributes.Count);
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
Assert.Equal("50.0", setPoint.Value);
Assert.Equal(DataType.Float, setPoint.DataType);
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
Assert.Equal("100.0", pressure.Value);
Assert.DoesNotContain(pump.Attributes, a => a.Name == "NewAttr");
// Alarms — exactly { HiAlarm }, PriorityLevel restored.
Assert.Single(pump.Alarms);
var hi = pump.Alarms.Single();
Assert.Equal("HiAlarm", hi.Name);
Assert.Equal(1, hi.PriorityLevel);
Assert.DoesNotContain(pump.Alarms, a => a.Name == "ExtraAlarm");
// Scripts — exactly { Init }, code restored.
Assert.Single(pump.Scripts);
var init = pump.Scripts.Single();
Assert.Equal("Init", init.Name);
Assert.Equal("return 1;", init.Code);
Assert.DoesNotContain(pump.Scripts, s => s.Name == "ExtraScript");
// Per-field audit rows — design doc enumerates Added / Updated /
// Deleted shapes; all of these should appear, all stamped with
// the BundleImportId from the result.
var newRows = await ctx.AuditLogEntries
.Where(a => a.Id > beforeMaxAuditId)
.ToListAsync();
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
// Attribute audit events.
Assert.Contains(newRows, r => r.Action == "TemplateAttributeUpdated" && r.EntityName == "Pump.SetPoint");
Assert.Contains(newRows, r => r.Action == "TemplateAttributeAdded" && r.EntityName == "Pump.Pressure");
Assert.Contains(newRows, r => r.Action == "TemplateAttributeDeleted" && r.EntityName == "Pump.NewAttr");
// Alarm audit events.
Assert.Contains(newRows, r => r.Action == "TemplateAlarmUpdated" && r.EntityName == "Pump.HiAlarm");
Assert.Contains(newRows, r => r.Action == "TemplateAlarmDeleted" && r.EntityName == "Pump.ExtraAlarm");
// Script audit events.
Assert.Contains(newRows, r => r.Action == "TemplateScriptUpdated" && r.EntityName == "Pump.Init");
Assert.Contains(newRows, r => r.Action == "TemplateScriptDeleted" && r.EntityName == "Pump.ExtraScript");
}
Assert.Equal(1, result.Overwritten);
}
[Fact]
public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle()
{
// T-002 regression. The ExternalSystem Overwrite branch used to write
// only EndpointUrl / AuthType / AuthConfiguration on the existing
// definition; the bundle's Methods collection was silently dropped on
// the floor. This test seeds an external system with one method
// shape, exports it, mutates the target's methods to diverge, then
// asserts that Overwrite restores every method AND emits per-field
// audit rows.
//
// Bundle shape (exported from "Erp"):
// Methods: [
// GetUser (GET /users/{id}, param=A, return=R),
// PostJob (POST /jobs, param=B, return=S),
// ]
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var sys = new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey");
ctx.ExternalSystemDefinitions.Add(sys);
await ctx.SaveChangesAsync();
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetUser", "GET", "/users/{id}")
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = "A",
ReturnDefinition = "R",
});
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostJob", "POST", "/jobs")
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = "B",
ReturnDefinition = "S",
});
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target so the methods diverge from the bundle:
// GetUser — Path mutated (UPDATE expected on Overwrite)
// PostJob — DELETED (ADD expected on Overwrite to restore)
// ExtraOp — ADDED (DELETE expected on Overwrite to remove)
// EndpointUrl / AuthType also mutated so the scalar update still fires.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
sys.EndpointUrl = "https://wrong.example";
sys.AuthType = "Basic";
var getUser = await ctx.ExternalSystemMethods
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "GetUser");
getUser.Path = "/wrong/path";
var postJob = await ctx.ExternalSystemMethods
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "PostJob");
ctx.ExternalSystemMethods.Remove(postJob);
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("ExtraOp", "DELETE", "/extra")
{
ExternalSystemDefinitionId = sys.Id,
ParameterDefinitions = "X",
ReturnDefinition = "Y",
});
await ctx.SaveChangesAsync();
}
// Capture the audit baseline so we can scope assertions to rows
// emitted by THIS apply.
int beforeMaxAuditId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
}
// Act — apply Overwrite on the external system.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — methods mirror the bundle exactly.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
Assert.Equal("https://erp.example", sys.EndpointUrl);
var methods = await ctx.ExternalSystemMethods
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
.ToListAsync();
Assert.Equal(2, methods.Count);
var getUser = methods.Single(m => m.Name == "GetUser");
Assert.Equal("GET", getUser.HttpMethod);
Assert.Equal("/users/{id}", getUser.Path);
Assert.Equal("A", getUser.ParameterDefinitions);
Assert.Equal("R", getUser.ReturnDefinition);
var postJob = methods.Single(m => m.Name == "PostJob");
Assert.Equal("POST", postJob.HttpMethod);
Assert.Equal("/jobs", postJob.Path);
Assert.Equal("B", postJob.ParameterDefinitions);
Assert.Equal("S", postJob.ReturnDefinition);
Assert.DoesNotContain(methods, m => m.Name == "ExtraOp");
// Per-field audit rows — design doc enumerates Added / Updated /
// Deleted shapes; all of these should appear, all stamped with
// the BundleImportId from the result.
var newRows = await ctx.AuditLogEntries
.Where(a => a.Id > beforeMaxAuditId)
.ToListAsync();
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodUpdated" && r.EntityName == "Erp.GetUser");
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodAdded" && r.EntityName == "Erp.PostJob");
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodDeleted" && r.EntityName == "Erp.ExtraOp");
}
Assert.Equal(1, result.Overwritten);
}
}