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
@@ -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,