fix(transport): transport template NativeAlarmSources — imports no longer amputate native alarm mirrors nor dangle instance overrides
New TemplateNativeAlarmSourceDto + init-only TemplateDto.NativeAlarmSources (empty default, additive — IsInherited placeholders carried for the collision detector). Exported in ToBundleContent, consumed in FromBundleContent + BuildTemplate, and a new SyncTemplateNativeAlarmSourcesAsync runs the Overwrite add/update/delete child sync with per-change audit rows. ArtifactDiff.CompareTemplate now DiffChildren over native sources so Preview reports a NativeAlarmSources field change. Proven by an end-to-end flatten test: an instance native-alarm-source override that dangled pre-fix now resolves against the re-imported template source. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -21,7 +21,7 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
|||||||
- Move **site-scoped configuration** (T18): `Site` definitions, site-scoped `DataConnection`s (protocol connections — distinct from External-System `DatabaseConnection`s), and `Instance`s along with their `InstanceAttributeOverride` / `InstanceAlarmOverride` / `InstanceNativeAlarmSourceOverride` / `InstanceConnectionBinding` children and `Area` membership (carried by name).
|
- Move **site-scoped configuration** (T18): `Site` definitions, site-scoped `DataConnection`s (protocol connections — distinct from External-System `DatabaseConnection`s), and `Instance`s along with their `InstanceAttributeOverride` / `InstanceAlarmOverride` / `InstanceNativeAlarmSourceOverride` / `InstanceConnectionBinding` children and `Area` membership (carried by name).
|
||||||
- Reconcile cross-environment site identifiers and connection names through the **name-mapping subsystem** (`BundleNameMap`): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping").
|
- Reconcile cross-environment site identifiers and connection names through the **name-mapping subsystem** (`BundleNameMap`): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping").
|
||||||
- Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`.
|
- Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`.
|
||||||
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`.
|
- Serialize and deserialize all transportable entity types to/from bundle DTOs, carving secret fields into an isolated `SecretsBlock`. A template travels with **all** of its child collections — `TemplateAttribute`, `TemplateAlarm`, `TemplateScript`, `TemplateComposition`, and `TemplateNativeAlarmSource` (including `IsInherited` placeholder rows, which the collision detector depends on) — each field-faithful on Add and reconciled add/update/delete on Overwrite. Carrying the native alarm sources is what keeps an imported `InstanceNativeAlarmSourceOverride` from dangling against a source the target template would otherwise lack.
|
||||||
- Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given.
|
- Encrypt content with AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) when a passphrase is supplied; leave content plaintext with a UI warning and an `UnencryptedBundleExport` audit event when none is given.
|
||||||
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
|
||||||
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ public sealed class ArtifactDiff
|
|||||||
"Compositions",
|
"Compositions",
|
||||||
changes);
|
changes);
|
||||||
|
|
||||||
|
DiffChildren(
|
||||||
|
existing.NativeAlarmSources,
|
||||||
|
incoming.NativeAlarmSources,
|
||||||
|
e => e.Name,
|
||||||
|
i => i.Name,
|
||||||
|
NativeAlarmSourcesEqual,
|
||||||
|
"NativeAlarmSources",
|
||||||
|
changes);
|
||||||
|
|
||||||
return BuildItem("Template", incoming.Name, changes);
|
return BuildItem("Template", incoming.Name, changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +674,15 @@ public sealed class ArtifactDiff
|
|||||||
&& e.ReturnDefinition == i.ReturnDefinition
|
&& e.ReturnDefinition == i.ReturnDefinition
|
||||||
&& e.IsLocked == i.IsLocked;
|
&& e.IsLocked == i.IsLocked;
|
||||||
|
|
||||||
|
private static bool NativeAlarmSourcesEqual(TemplateNativeAlarmSource e, TemplateNativeAlarmSourceDto i) =>
|
||||||
|
e.Description == i.Description
|
||||||
|
&& e.ConnectionName == i.ConnectionName
|
||||||
|
&& e.SourceReference == i.SourceReference
|
||||||
|
&& e.ConditionFilter == i.ConditionFilter
|
||||||
|
&& e.IsLocked == i.IsLocked
|
||||||
|
&& e.IsInherited == i.IsInherited
|
||||||
|
&& e.LockedInDerived == i.LockedInDerived;
|
||||||
|
|
||||||
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
|
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
|
||||||
e.HttpMethod == i.HttpMethod
|
e.HttpMethod == i.HttpMethod
|
||||||
&& e.Path == i.Path
|
&& e.Path == i.Path
|
||||||
|
|||||||
@@ -1495,6 +1495,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
|
await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
|
||||||
await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
|
await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
|
||||||
await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
|
await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
|
||||||
|
await SyncTemplateNativeAlarmSourcesAsync(ex, dto, user, ct).ConfigureAwait(false);
|
||||||
summary.Overwritten++;
|
summary.Overwritten++;
|
||||||
break;
|
break;
|
||||||
case ResolutionAction.Add:
|
case ResolutionAction.Add:
|
||||||
@@ -1578,6 +1579,19 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
LockedInDerived = s.LockedInDerived,
|
LockedInDerived = s.LockedInDerived,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
foreach (var n in dto.NativeAlarmSources)
|
||||||
|
{
|
||||||
|
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
|
||||||
|
{
|
||||||
|
Description = n.Description,
|
||||||
|
ConnectionName = n.ConnectionName,
|
||||||
|
SourceReference = n.SourceReference,
|
||||||
|
ConditionFilter = n.ConditionFilter,
|
||||||
|
IsLocked = n.IsLocked,
|
||||||
|
IsInherited = n.IsInherited,
|
||||||
|
LockedInDerived = n.LockedInDerived,
|
||||||
|
});
|
||||||
|
}
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1936,6 +1950,123 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #05-T5 — Overwrite child sync (native alarm sources). Mirrors
|
||||||
|
/// <see cref="SyncTemplateAttributesAsync"/> for the
|
||||||
|
/// <c>NativeAlarmSources</c> collection: diffs the DTO's sources against the
|
||||||
|
/// existing template's sources by name and stages add / update / delete on
|
||||||
|
/// the tracked entity. Audit rows:
|
||||||
|
/// <c>TemplateNativeAlarmSourceAdded</c> / <c>…Updated</c> / <c>…Deleted</c>.
|
||||||
|
/// <para>
|
||||||
|
/// Update detection compares every writable field (Description,
|
||||||
|
/// ConnectionName, SourceReference, ConditionFilter, IsLocked, IsInherited,
|
||||||
|
/// LockedInDerived) — an idempotent overwrite produces no audit noise.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private async Task SyncTemplateNativeAlarmSourcesAsync(
|
||||||
|
Template ex,
|
||||||
|
TemplateDto dto,
|
||||||
|
string user,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existingByName = ex.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
||||||
|
var dtoByName = dto.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Deletes — sources present on the target but not in the bundle.
|
||||||
|
foreach (var existing in existingByName.Values.ToList())
|
||||||
|
{
|
||||||
|
if (dtoByName.ContainsKey(existing.Name)) continue;
|
||||||
|
await _templateRepo.DeleteTemplateNativeAlarmSourceAsync(existing.Id, ct).ConfigureAwait(false);
|
||||||
|
ex.NativeAlarmSources.Remove(existing);
|
||||||
|
await _auditService.LogAsync(
|
||||||
|
user,
|
||||||
|
"TemplateNativeAlarmSourceDeleted",
|
||||||
|
"TemplateNativeAlarmSource",
|
||||||
|
existing.Id.ToString(),
|
||||||
|
$"{ex.Name}.{existing.Name}",
|
||||||
|
new { TemplateName = ex.Name, SourceName = existing.Name },
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds + Updates.
|
||||||
|
foreach (var srcDto in dto.NativeAlarmSources)
|
||||||
|
{
|
||||||
|
if (existingByName.TryGetValue(srcDto.Name, out var current))
|
||||||
|
{
|
||||||
|
bool changed =
|
||||||
|
!string.Equals(current.Description, srcDto.Description, StringComparison.Ordinal) ||
|
||||||
|
!string.Equals(current.ConnectionName, srcDto.ConnectionName, StringComparison.Ordinal) ||
|
||||||
|
!string.Equals(current.SourceReference, srcDto.SourceReference, StringComparison.Ordinal) ||
|
||||||
|
!string.Equals(current.ConditionFilter, srcDto.ConditionFilter, StringComparison.Ordinal) ||
|
||||||
|
current.IsLocked != srcDto.IsLocked ||
|
||||||
|
current.IsInherited != srcDto.IsInherited ||
|
||||||
|
current.LockedInDerived != srcDto.LockedInDerived;
|
||||||
|
if (!changed) continue;
|
||||||
|
|
||||||
|
current.Description = srcDto.Description;
|
||||||
|
current.ConnectionName = srcDto.ConnectionName;
|
||||||
|
current.SourceReference = srcDto.SourceReference;
|
||||||
|
current.ConditionFilter = srcDto.ConditionFilter;
|
||||||
|
current.IsLocked = srcDto.IsLocked;
|
||||||
|
current.IsInherited = srcDto.IsInherited;
|
||||||
|
current.LockedInDerived = srcDto.LockedInDerived;
|
||||||
|
await _templateRepo.UpdateTemplateNativeAlarmSourceAsync(current, ct).ConfigureAwait(false);
|
||||||
|
await _auditService.LogAsync(
|
||||||
|
user,
|
||||||
|
"TemplateNativeAlarmSourceUpdated",
|
||||||
|
"TemplateNativeAlarmSource",
|
||||||
|
current.Id.ToString(),
|
||||||
|
$"{ex.Name}.{current.Name}",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
TemplateName = ex.Name,
|
||||||
|
SourceName = current.Name,
|
||||||
|
current.Description,
|
||||||
|
current.ConnectionName,
|
||||||
|
current.SourceReference,
|
||||||
|
current.ConditionFilter,
|
||||||
|
current.IsLocked,
|
||||||
|
current.IsInherited,
|
||||||
|
current.LockedInDerived,
|
||||||
|
},
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var newSource = new TemplateNativeAlarmSource(srcDto.Name)
|
||||||
|
{
|
||||||
|
Description = srcDto.Description,
|
||||||
|
ConnectionName = srcDto.ConnectionName,
|
||||||
|
SourceReference = srcDto.SourceReference,
|
||||||
|
ConditionFilter = srcDto.ConditionFilter,
|
||||||
|
IsLocked = srcDto.IsLocked,
|
||||||
|
IsInherited = srcDto.IsInherited,
|
||||||
|
LockedInDerived = srcDto.LockedInDerived,
|
||||||
|
};
|
||||||
|
ex.NativeAlarmSources.Add(newSource);
|
||||||
|
await _auditService.LogAsync(
|
||||||
|
user,
|
||||||
|
"TemplateNativeAlarmSourceAdded",
|
||||||
|
"TemplateNativeAlarmSource",
|
||||||
|
"0",
|
||||||
|
$"{ex.Name}.{newSource.Name}",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
TemplateName = ex.Name,
|
||||||
|
SourceName = newSource.Name,
|
||||||
|
newSource.Description,
|
||||||
|
newSource.ConnectionName,
|
||||||
|
newSource.SourceReference,
|
||||||
|
newSource.ConditionFilter,
|
||||||
|
newSource.IsLocked,
|
||||||
|
newSource.IsInherited,
|
||||||
|
newSource.LockedInDerived,
|
||||||
|
},
|
||||||
|
ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pass A of the post-template-flush rewire.
|
/// Pass A of the post-template-flush rewire.
|
||||||
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
|
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
|
||||||
|
|||||||
@@ -126,7 +126,39 @@ public sealed record TemplateDto(
|
|||||||
IReadOnlyList<TemplateAttributeDto> Attributes,
|
IReadOnlyList<TemplateAttributeDto> Attributes,
|
||||||
IReadOnlyList<TemplateAlarmDto> Alarms,
|
IReadOnlyList<TemplateAlarmDto> Alarms,
|
||||||
IReadOnlyList<TemplateScriptDto> Scripts,
|
IReadOnlyList<TemplateScriptDto> Scripts,
|
||||||
IReadOnlyList<TemplateCompositionDto> Compositions);
|
IReadOnlyList<TemplateCompositionDto> Compositions)
|
||||||
|
{
|
||||||
|
// Template-defined native alarm source bindings. Modeled as an init-only
|
||||||
|
// property with an empty default (NOT a positional ctor param) because an
|
||||||
|
// IReadOnlyList has no valid compile-time default for a positional
|
||||||
|
// parameter, and for the same forward/source-compat reasons as the
|
||||||
|
// site/instance arrays on BundleContentDto:
|
||||||
|
// 1. Forward-compat: a bundle written before native alarm sources were
|
||||||
|
// transported has no NativeAlarmSources field and deserializes this to
|
||||||
|
// Array.Empty<> — consumers always see an empty list, never null.
|
||||||
|
// 2. Source-compat: every existing positional `new TemplateDto(...)` caller
|
||||||
|
// keeps compiling; producers opt in via the object-initializer.
|
||||||
|
// IsInherited placeholder rows ARE carried (the collision detector depends
|
||||||
|
// on them), mirroring how the flattener treats inherited native sources.
|
||||||
|
public IReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSources { get; init; } =
|
||||||
|
Array.Empty<TemplateNativeAlarmSourceDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A template-defined native alarm source binding. Carries every writable field
|
||||||
|
/// of the <c>TemplateNativeAlarmSource</c> entity, including the inheritance/lock
|
||||||
|
/// flags, so an export→import round-trip is field-faithful and instance-level
|
||||||
|
/// overrides of the source no longer dangle in the target environment.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TemplateNativeAlarmSourceDto(
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
string ConnectionName,
|
||||||
|
string SourceReference,
|
||||||
|
string? ConditionFilter,
|
||||||
|
bool IsLocked,
|
||||||
|
bool IsInherited,
|
||||||
|
bool LockedInDerived);
|
||||||
|
|
||||||
public sealed record TemplateAttributeDto(
|
public sealed record TemplateAttributeDto(
|
||||||
string Name,
|
string Name,
|
||||||
|
|||||||
@@ -91,7 +91,20 @@ public sealed class EntitySerializer
|
|||||||
LockedInDerived: s.LockedInDerived)).ToList(),
|
LockedInDerived: s.LockedInDerived)).ToList(),
|
||||||
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
|
Compositions: t.Compositions.Select(c => new TemplateCompositionDto(
|
||||||
InstanceName: c.InstanceName,
|
InstanceName: c.InstanceName,
|
||||||
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList());
|
ComposedTemplateName: templateNameById.TryGetValue(c.ComposedTemplateId, out var cn) ? cn : string.Empty)).ToList())
|
||||||
|
{
|
||||||
|
// Native alarm sources carry ALL rows including IsInherited
|
||||||
|
// placeholders — the collision detector depends on them.
|
||||||
|
NativeAlarmSources = t.NativeAlarmSources.Select(n => new TemplateNativeAlarmSourceDto(
|
||||||
|
Name: n.Name,
|
||||||
|
Description: n.Description,
|
||||||
|
ConnectionName: n.ConnectionName,
|
||||||
|
SourceReference: n.SourceReference,
|
||||||
|
ConditionFilter: n.ConditionFilter,
|
||||||
|
IsLocked: n.IsLocked,
|
||||||
|
IsInherited: n.IsInherited,
|
||||||
|
LockedInDerived: n.LockedInDerived)).ToList(),
|
||||||
|
};
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
|
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
@@ -352,6 +365,20 @@ public sealed class EntitySerializer
|
|||||||
LockedInDerived = s.LockedInDerived,
|
LockedInDerived = s.LockedInDerived,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
foreach (var n in dto.NativeAlarmSources)
|
||||||
|
{
|
||||||
|
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
|
||||||
|
{
|
||||||
|
TemplateId = t.Id,
|
||||||
|
Description = n.Description,
|
||||||
|
ConnectionName = n.ConnectionName,
|
||||||
|
SourceReference = n.SourceReference,
|
||||||
|
ConditionFilter = n.ConditionFilter,
|
||||||
|
IsLocked = n.IsLocked,
|
||||||
|
IsInherited = n.IsInherited,
|
||||||
|
LockedInDerived = n.LockedInDerived,
|
||||||
|
});
|
||||||
|
}
|
||||||
return t;
|
return t;
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
+338
@@ -0,0 +1,338 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #05-T5: a template's <see cref="TemplateNativeAlarmSource"/> collection must
|
||||||
|
/// survive an export→import round-trip (Add + Overwrite sync), diff in Preview,
|
||||||
|
/// and — crucially — carry across environments so an instance-level override of a
|
||||||
|
/// native alarm source no longer dangles in the target. Before this fix the
|
||||||
|
/// importer amputated the template's native sources entirely, so an imported
|
||||||
|
/// instance override referenced a source that did not exist. Same in-memory host
|
||||||
|
/// pattern as <see cref="TemplateScriptFidelityTests"/> / <see cref="BundleImporterApplyTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NativeAlarmSourceImportTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
|
||||||
|
public NativeAlarmSourceImportTests()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<IConfiguration>(
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||||
|
|
||||||
|
var dbName = $"NativeAlarmSourceImportTests_{Guid.NewGuid()}";
|
||||||
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
||||||
|
.UseInMemoryDatabase(dbName)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||||
|
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||||
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||||
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||||
|
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
||||||
|
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
||||||
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||||
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
|
services.AddTransport();
|
||||||
|
services.AddTemplateEngine();
|
||||||
|
services.AddDeploymentManager();
|
||||||
|
|
||||||
|
_provider = services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _provider.Dispose();
|
||||||
|
|
||||||
|
private async Task<Guid> ExportAllTemplatesAndLoadAsync()
|
||||||
|
{
|
||||||
|
Stream bundleStream;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
||||||
|
var selection = new ExportSelection(
|
||||||
|
TemplateIds: templateIds,
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
IncludeDependencies: false);
|
||||||
|
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||||
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await bundleStream.CopyToAsync(ms);
|
||||||
|
ms.Position = 0;
|
||||||
|
|
||||||
|
await using var loadScope = _provider.CreateAsyncScope();
|
||||||
|
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||||
|
return session.SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateNativeAlarmSource MakeSource(
|
||||||
|
string name = "Boiler",
|
||||||
|
string connection = "OpcUaPrimary",
|
||||||
|
string sourceRef = "ns=3;s=Boiler.Alarm",
|
||||||
|
string? filter = "severity>500",
|
||||||
|
bool locked = false) => new(name)
|
||||||
|
{
|
||||||
|
Description = "boiler native alarms",
|
||||||
|
ConnectionName = connection,
|
||||||
|
SourceReference = sourceRef,
|
||||||
|
ConditionFilter = filter,
|
||||||
|
IsLocked = locked,
|
||||||
|
IsInherited = false,
|
||||||
|
LockedInDerived = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_Add_CarriesTemplateNativeAlarmSources()
|
||||||
|
{
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var t = new Template("Pump") { Description = "tpl" };
|
||||||
|
t.NativeAlarmSources.Add(MakeSource(locked: true));
|
||||||
|
ctx.Templates.Add(t);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.Templates.RemoveRange(ctx.Templates);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var src = await ctx.Templates
|
||||||
|
.Where(t => t.Name == "Pump")
|
||||||
|
.SelectMany(t => t.NativeAlarmSources)
|
||||||
|
.SingleAsync();
|
||||||
|
Assert.Equal("Boiler", src.Name);
|
||||||
|
Assert.Equal("boiler native alarms", src.Description);
|
||||||
|
Assert.Equal("OpcUaPrimary", src.ConnectionName);
|
||||||
|
Assert.Equal("ns=3;s=Boiler.Alarm", src.SourceReference);
|
||||||
|
Assert.Equal("severity>500", src.ConditionFilter);
|
||||||
|
Assert.True(src.IsLocked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_Overwrite_SyncsNativeAlarmSources()
|
||||||
|
{
|
||||||
|
// Source has Keep (changed ConditionFilter later) + Add ("Chiller"); target
|
||||||
|
// will hold Keep (different filter) + Drop ("Legacy"). Import Overwrite must
|
||||||
|
// update Keep, add Chiller, delete Legacy — with one audit row each.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var t = new Template("Pump") { Description = "same" };
|
||||||
|
t.NativeAlarmSources.Add(MakeSource("Keep", filter: "severity>500"));
|
||||||
|
t.NativeAlarmSources.Add(MakeSource("Chiller", connection: "OpcUaChiller", sourceRef: "ns=4;s=Chiller"));
|
||||||
|
ctx.Templates.Add(t);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||||
|
|
||||||
|
// Mutate target: change Keep's filter, drop Chiller, add Legacy.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump");
|
||||||
|
t.NativeAlarmSources.Single(s => s.Name == "Keep").ConditionFilter = "severity>900";
|
||||||
|
t.NativeAlarmSources.Remove(t.NativeAlarmSources.Single(s => s.Name == "Chiller"));
|
||||||
|
t.NativeAlarmSources.Add(MakeSource("Legacy", connection: "OpcUaLegacy", sourceRef: "ns=9;s=Legacy"));
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var sources = await ctx.Templates
|
||||||
|
.Where(t => t.Name == "Pump")
|
||||||
|
.SelectMany(t => t.NativeAlarmSources)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(2, sources.Count);
|
||||||
|
Assert.Equal("severity>500", sources.Single(s => s.Name == "Keep").ConditionFilter); // updated back
|
||||||
|
Assert.Contains(sources, s => s.Name == "Chiller"); // added
|
||||||
|
Assert.DoesNotContain(sources, s => s.Name == "Legacy"); // deleted
|
||||||
|
|
||||||
|
Assert.True(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||||
|
a.Action == "TemplateNativeAlarmSourceUpdated" && a.EntityName == "Pump.Keep"));
|
||||||
|
Assert.True(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||||
|
a.Action == "TemplateNativeAlarmSourceAdded" && a.EntityName == "Pump.Chiller"));
|
||||||
|
Assert.True(await ctx.AuditLogEntries.AnyAsync(a =>
|
||||||
|
a.Action == "TemplateNativeAlarmSourceDeleted" && a.EntityName == "Pump.Legacy"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Preview_DiffsNativeAlarmSources()
|
||||||
|
{
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var t = new Template("Pump") { Description = "tpl" };
|
||||||
|
t.NativeAlarmSources.Add(MakeSource("Boiler", filter: "severity>500"));
|
||||||
|
ctx.Templates.Add(t);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||||
|
|
||||||
|
// Mutate the target source so the diff has something to report.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump");
|
||||||
|
t.NativeAlarmSources.Single().ConditionFilter = "severity>900";
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var preview = await importer.PreviewAsync(sessionId);
|
||||||
|
var pump = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
|
||||||
|
Assert.Equal(ConflictKind.Modified, pump.Kind);
|
||||||
|
Assert.NotNull(pump.FieldDiffJson);
|
||||||
|
Assert.Contains("NativeAlarmSources", pump.FieldDiffJson!, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_InstanceOverride_NoLongerDangles()
|
||||||
|
{
|
||||||
|
// Target holds a template + site + instance whose override repoints the
|
||||||
|
// "Boiler" native source. We export the template WITH the source, delete
|
||||||
|
// the target's copy of the source (so the override dangles), then import
|
||||||
|
// Overwrite — which re-adds the source — and flatten: the resolved config
|
||||||
|
// must now carry "Boiler" with the instance override applied.
|
||||||
|
int instanceId;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var t = new Template("Pump") { Description = "tpl" };
|
||||||
|
t.NativeAlarmSources.Add(MakeSource("Boiler", connection: "OpcUaPrimary", locked: false));
|
||||||
|
ctx.Templates.Add(t);
|
||||||
|
|
||||||
|
var site = new Site("Plant 1", "plant-1")
|
||||||
|
{
|
||||||
|
NodeAAddress = "akka://s@10.0.0.1:2552",
|
||||||
|
NodeBAddress = "akka://s@10.0.0.2:2552",
|
||||||
|
};
|
||||||
|
ctx.Sites.Add(site);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
ctx.DataConnections.Add(new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
||||||
|
{
|
||||||
|
PrimaryConfiguration = "{}",
|
||||||
|
});
|
||||||
|
ctx.DataConnections.Add(new DataConnection("OpcUaBackup", "OpcUa", site.Id)
|
||||||
|
{
|
||||||
|
PrimaryConfiguration = "{}",
|
||||||
|
});
|
||||||
|
|
||||||
|
var instance = new Instance("Pump-01")
|
||||||
|
{
|
||||||
|
TemplateId = t.Id,
|
||||||
|
SiteId = site.Id,
|
||||||
|
State = InstanceState.NotDeployed,
|
||||||
|
};
|
||||||
|
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Boiler")
|
||||||
|
{
|
||||||
|
ConnectionNameOverride = "OpcUaBackup",
|
||||||
|
});
|
||||||
|
ctx.Instances.Add(instance);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
instanceId = instance.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionId = await ExportAllTemplatesAndLoadAsync();
|
||||||
|
|
||||||
|
// Delete the target template's native source → the override now dangles.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var t = await ctx.Templates.Include(x => x.NativeAlarmSources).SingleAsync(x => x.Name == "Pump");
|
||||||
|
t.NativeAlarmSources.Clear();
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-check: with the source gone, flatten resolves no "Boiler".
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
|
||||||
|
var pre = await pipeline.FlattenAndValidateAsync(instanceId, CancellationToken.None, validateScripts: false);
|
||||||
|
Assert.True(pre.IsSuccess);
|
||||||
|
Assert.DoesNotContain(pre.Value!.Configuration.NativeAlarmSources, s => s.CanonicalName == "Boiler");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import Overwrite re-adds the template source.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
await importer.ApplyAsync(sessionId,
|
||||||
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
||||||
|
user: "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the override resolves against the re-imported source.
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
|
||||||
|
var post = await pipeline.FlattenAndValidateAsync(instanceId, CancellationToken.None, validateScripts: false);
|
||||||
|
Assert.True(post.IsSuccess);
|
||||||
|
var boiler = Assert.Single(post.Value!.Configuration.NativeAlarmSources, s => s.CanonicalName == "Boiler");
|
||||||
|
Assert.Equal("OpcUaBackup", boiler.ConnectionName); // instance override applied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,6 +202,46 @@ public sealed class EntitySerializerTests
|
|||||||
Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived);
|
Assert.True(Assert.Single(rtBasic.Scripts).LockedInDerived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_template_preserves_NativeAlarmSources()
|
||||||
|
{
|
||||||
|
var basic = new Template("Basic") { Id = 1 };
|
||||||
|
basic.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Boiler")
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
TemplateId = 1,
|
||||||
|
Description = "boiler alarms",
|
||||||
|
ConnectionName = "OpcUaPrimary",
|
||||||
|
SourceReference = "ns=3;s=Boiler.Alarm",
|
||||||
|
ConditionFilter = "severity>500",
|
||||||
|
IsLocked = true,
|
||||||
|
IsInherited = false,
|
||||||
|
LockedInDerived = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var aggregate = MakeEmptyAggregate() with { Templates = new[] { basic } };
|
||||||
|
|
||||||
|
var sut = new EntitySerializer();
|
||||||
|
var dto = sut.ToBundleContent(aggregate);
|
||||||
|
|
||||||
|
var dtoSrc = Assert.Single(Assert.Single(dto.Templates).NativeAlarmSources);
|
||||||
|
Assert.Equal("Boiler", dtoSrc.Name);
|
||||||
|
Assert.Equal("OpcUaPrimary", dtoSrc.ConnectionName);
|
||||||
|
Assert.Equal("ns=3;s=Boiler.Alarm", dtoSrc.SourceReference);
|
||||||
|
Assert.Equal("severity>500", dtoSrc.ConditionFilter);
|
||||||
|
Assert.True(dtoSrc.IsLocked);
|
||||||
|
Assert.True(dtoSrc.LockedInDerived);
|
||||||
|
|
||||||
|
var rtSrc = Assert.Single(Assert.Single(sut.FromBundleContent(dto).Templates).NativeAlarmSources);
|
||||||
|
Assert.Equal("Boiler", rtSrc.Name);
|
||||||
|
Assert.Equal("boiler alarms", rtSrc.Description);
|
||||||
|
Assert.Equal("OpcUaPrimary", rtSrc.ConnectionName);
|
||||||
|
Assert.Equal("ns=3;s=Boiler.Alarm", rtSrc.SourceReference);
|
||||||
|
Assert.Equal("severity>500", rtSrc.ConditionFilter);
|
||||||
|
Assert.True(rtSrc.IsLocked);
|
||||||
|
Assert.True(rtSrc.LockedInDerived);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false()
|
public void OldForm_bundle_json_without_LockedInDerived_deserializes_to_false()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user