fix(transport): carry derived composition-slot templates and rebuild them through the compose flow (ScadaBridge#118)

Bundles did not record that a template was a derived slot template, so the
importer re-created every slot as a BASE template named after the slot.

- Export: TemplateDto gains an additive, null-omitted `Derivation`
  (owner path + base path by stable names); a derived template carries only
  its own rows (overrides/additions, not inherited placeholders). The closure
  pulls in each derived template's base/owner; base orders first.
  schemaVersion 1.1 -> 1.2, bundleFormatVersion stays 1.
- Import: base templates go through the ordinary passes; derived templates are
  ordered topologically (DerivedTemplatePlan; cycle / duplicate / malformed =
  preview Blocker + SemanticValidationException before anything is staged),
  their owner and base resolved by path (#100 root rule), and the slot is
  created with TemplateService.AddCompositionAsync, synced in place when it
  already exists with the same base, or deleted+recomposed when the base
  differs; own rows applied as overrides, then ResyncInheritedMembersAsync.
  Missing owner/base fails that record (BundleImportDerivedTemplateUnresolved
  audit + ImportResult warning), never creating a base in its place.
- Preview/diff: derived rows are keyed by qualified name and compared against
  the target slot at the same owner path (own rows + base), so an unchanged
  slot is Identical and a same-named base template is never matched.
- Pre-#118 bundles import exactly as before (new fixture
  pre-118-derived-plaintext.scadabundle, written by a13aabc7). The field is in
  content, pinned by the signed manifest's contentHash.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 05:02:06 -05:00
parent fb04b54d62
commit 84a050420f
14 changed files with 1676 additions and 41 deletions
+15 -3
View File
@@ -59,7 +59,7 @@ zip-bomb guard, and the exporter never emits per-script files.
```json
{
"bundleFormatVersion": 1,
"schemaVersion": "1.1",
"schemaVersion": "1.2",
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
@@ -106,6 +106,7 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
- Unknown top-level entity types in `contents[]` surface in the import preview as "skipped — unsupported in this version" rather than failing the whole import.
- `bundleFormatVersion` newer than what the importer supports produces a hard refusal at upload with a clear upgrade message.
- `schemaVersion` minor increments are **additive-only**. T18 bumped it `1.0 → 1.1` (adding the `sites`/`dataConnections`/`instances` arrays). `bundleFormatVersion` stays `1`; `ManifestValidator` gates solely on `bundleFormatVersion`, so a pre-T18 importer still accepts a 1.1 bundle and simply lists the new site/instance/connection entries as "skipped — unsupported in this version".
- ScadaBridge#118 bumped it `1.1 → 1.2`: a derived (composition-slot) template carries a `Derivation` object (see [Derived templates](#derived-composition-slot-templates-scadabridge118)). It is omitted on base templates (`WhenWritingNull`), so a base template's JSON is unchanged; an older importer ignores the unknown property and imports the slot the old (wrong) way; a 1.0/1.1 bundle has no derivation anywhere and imports exactly as before (pinned by `Fixtures/pre-118-derived-plaintext.scadabundle`). `bundleFormatVersion` stays `1`. The field lives in `content.json`/`content.enc`, whose SHA-256 is the manifest's `contentHash`, so a signed bundle's signature over `manifest.json` covers it (an edited derivation fails the hash check, or — with a recomputed hash — the signature).
## Architecture
@@ -314,6 +315,16 @@ Site identifiers and connection names are environment-specific, so a bundle expo
- `InstanceConnectionBinding.DataConnectionId` is resolved via the connection map, with a **Pass-2** that also binds connections which already exist in the target but were not carried in the bundle.
- `InstanceNativeAlarmSourceOverride.ConnectionNameOverride` is rewritten to the **mapped target** connection name.
### Derived (composition-slot) templates (ScadaBridge#118)
Composing a template into a slot creates a slot-owned **derived** template (`IsDerived`, `ParentTemplateId` = the base, `OwnerCompositionId` = the slot) named after the slot. Before #118 the bundle did not say so, and the importer re-created every such template as a **base** template named after its slot. Owner decision (2026-11-10): carry the relationship and rebuild the slot through the normal compose flow.
- **Export.** A derived template's `TemplateDto` carries `Derivation` = `{ OwnerTemplateName, SlotPath, BaseTemplateName, BaseSlotPath }`: its owner path (the base template at the root of the owner chain plus the slot names down to it — its qualified name, e.g. `Pump.M1` or `Station.P.M1`) and its base path (a base template, or for a cascade-created nested slot the source-side derived template, e.g. `Pump.M1`). Its member lists carry only its **own** rows (`IsInherited == false`: overrides and additions), not the inherited placeholders. The dependency closure pulls in each derived template's base (and owner), and the manifest order puts the base first.
- **Identity.** A derived template is previewed and resolved under its **qualified name** (`Template` / `Pump.M1`), compared against the target's template at the same owner path — never against a base template that shares the slot name. The diff compares the derived-ness, the base (by qualified name) and the target slot's own rows only, so a re-import of an unchanged slot is `Identical` (auto-Skip). A Rename resolution on a derived row is treated as Add/Overwrite (the slot name is fixed by the owner).
- **Order.** `DerivedTemplatePlan` orders derived templates after their owner and base (topological — the bundle order does not matter). A cycle, a duplicate qualified name or a malformed derivation is a preview Blocker and a `SemanticValidationException` before anything is staged.
- **Apply.** The ordinary template passes see base templates only (and the owner's composition pass leaves the rows of its bundled derived slots alone). After the acyclicity guard, each derived template's owner and base are resolved by path — root by the #100 rule (exact name, base template first, a case-variant base as fallback; a name held only by derived templates is never a root) then slot by slot — in the bundle-applied/target state. A missing slot is created with `TemplateService.AddCompositionAsync` (the Central UI / management API compose flow: derived row, placeholders, nested cascade slots, cycle and collision checks); an existing slot with the same base is synced **in place** (same row, so nested slots deriving from it keep their FK); one with another base is removed with `DeleteCompositionAsync` and recomposed. The bundled own rows are then applied as overrides, own rows the bundle no longer carries revert to placeholders, and `ResyncInheritedMembersAsync` reconciles the placeholders. Derived templates skip the import-time one-template semantic validation (their inherited members are not in the DTO); the trust gate still runs over their scripts.
- **Missing owner/base.** If either resolves in neither the bundle nor the target (e.g. the base was Skipped), that record fails: a `BundleImportDerivedTemplateUnresolved` audit row and an `ImportResult.Warnings` entry name it, and no base template is created in its place.
### Stale-Instance Signaling
There is no explicit stale-mark write. Overwriting a template during import changes its content, which naturally changes the flattened-config hash that `DeploymentService.CompareAsync` computes at query time. The Deployments page already surfaces any instance whose computed hash differs from the deployed snapshot — no new mechanism is required.
@@ -405,6 +416,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) |
| Imported derived template's base template missing (unresolved / skipped) | `BundleImportBaseTemplateUnresolved` (warning; `ParentTemplateId` left null) |
| Imported derived composition-slot template's owner or base missing (#118) | `BundleImportDerivedTemplateUnresolved` (warning; the slot is not created and no base template is created instead) |
**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>`.
@@ -476,7 +488,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
```json
{
"bundleFormatVersion": 1,
"schemaVersion": "1.1",
"schemaVersion": "1.2",
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
@@ -543,7 +555,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
| Field | Description |
|---|---|
| `bundleFormatVersion` | Integer. Importer hard-refuses any value other than the code constant `ManifestBuilder.CurrentBundleFormatVersion`. Stays `1` through T18. The schema and format versions are code constants by design — there is no `Transport:SchemaVersionMajor` option (removed 2026-10-22; it was validated but never read). |
| `schemaVersion` | Semver string. Minor increments are additive-only and accepted by older importers. T18 bumped this `1.0 → 1.1` (adding the `sites`/`dataConnections`/`instances` content arrays); a pre-T18 importer lists those new entries as "skipped — unsupported in this version" since `ManifestValidator` gates only on `bundleFormatVersion`. |
| `schemaVersion` | Semver string. Minor increments are additive-only and accepted by older importers. T18 bumped this `1.0 → 1.1` (adding the `sites`/`dataConnections`/`instances` content arrays); a pre-T18 importer lists those new entries as "skipped — unsupported in this version" since `ManifestValidator` gates only on `bundleFormatVersion`. ScadaBridge#118 bumped it `1.1 → 1.2` (the additive `Derivation` on derived composition-slot templates). |
| `createdAtUtc` | ISO-8601 UTC timestamp of when the export was created. |
| `sourceEnvironment` | The `SourceEnvironment` name of the exporting cluster (from `TransportOptions`). Displayed in the import wizard and required to be retyped at the confirm step. |
| `exportedBy` | Authenticated username of the person who performed the export. |
@@ -422,11 +422,21 @@ public sealed class DependencyResolver
IReadOnlyCollection<Template> frontier = templates.Values.ToList();
while (frontier.Count > 0)
{
frontier = await LoadMissingTemplatesAsync(
frontier.SelectMany(t => t.Compositions).Select(c => c.ComposedTemplateId),
templates,
templateLookup,
ct).ConfigureAwait(false);
// ScadaBridge#118: a derived (composition-slot) template is rebuilt on import by
// composing its BASE under its OWNER, so both travel with it — the base through
// ParentTemplateId, the owner through the OwnerCompositionId row (normally the
// owner is what pulled the slot in; it is looked up only when it is not loaded).
var ids = frontier.SelectMany(t => t.Compositions).Select(c => c.ComposedTemplateId).ToList();
foreach (var derived in frontier.Where(t => t.IsDerived))
{
if (derived.ParentTemplateId is { } baseId) ids.Add(baseId);
if (derived.OwnerCompositionId is not { } compId) continue;
if (templates.Values.Any(t => t.Compositions.Any(c => c.Id == compId))) continue;
var composition = await _templates.GetTemplateCompositionByIdAsync(compId, ct).ConfigureAwait(false);
if (composition is not null) ids.Add(composition.TemplateId);
}
frontier = await LoadMissingTemplatesAsync(ids, templates, templateLookup, ct).ConfigureAwait(false);
}
}
@@ -581,6 +591,13 @@ public sealed class DependencyResolver
outEdges[comp.ComposedTemplateId].Add(t.Id);
inDegree[t.Id]++;
}
// ScadaBridge#118: a derived slot template also follows its base.
if (t.IsDerived && t.ParentTemplateId is { } baseId && baseId != t.Id && nodes.ContainsKey(baseId))
{
outEdges[baseId].Add(t.Id);
inDegree[t.Id]++;
}
}
var queue = new Queue<int>(
@@ -629,8 +646,11 @@ public sealed class DependencyResolver
}
foreach (var t in templates)
{
var deps = t.Compositions
.Select(c => $"Template:{(templates.FirstOrDefault(x => x.Id == c.ComposedTemplateId)?.Name ?? c.ComposedTemplateId.ToString())}")
var depIds = t.Compositions.Select(c => c.ComposedTemplateId).ToList();
// ScadaBridge#118: a derived slot template depends on its base too.
if (t.IsDerived && t.ParentTemplateId is { } baseId) depIds.Add(baseId);
var deps = depIds
.Select(id => $"Template:{(templates.FirstOrDefault(x => x.Id == id)?.Name ?? id.ToString())}")
.OrderBy(x => x, StringComparer.Ordinal)
.ToList();
entries.Add(new ManifestContentEntry("Template", t.Name, 1, deps));
@@ -74,11 +74,72 @@ public sealed class ArtifactDiff
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
AddIfDifferent(changes, "FolderName", FolderNameOf(existing, folderNameById), incoming.FolderName);
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing, templateNameById), incoming.BaseTemplateName);
CollectTemplateBodyChanges(incoming, existing, existing, templateNameById, changes);
return BuildItem("Template", incoming.Name, changes);
}
/// <summary>
/// ScadaBridge#118 — compares an incoming DERIVED (composition-slot) template against the
/// target's derived template at the same owner path. Derived-ness is part of the
/// identity: the item is keyed by the qualified name (<paramref name="qualifiedName"/>),
/// it is never matched against a base template that happens to share the slot name, and
/// a target slot that is not a derived template (a pre-derived composition straight onto
/// a base) reads as Modified. Only the target's OWN rows (overrides and additions,
/// <c>IsInherited == false</c>) are compared — the bundle carries no inherited
/// placeholders — together with the base the slot derives from (by qualified name).
/// </summary>
/// <param name="incoming">The incoming derived template (its <c>Derivation</c> is set).</param>
/// <param name="qualifiedName">The incoming template's qualified name (its preview identity).</param>
/// <param name="existing">The target's template at that owner path, or null if the slot is absent.</param>
/// <param name="existingBaseQualifiedName">The qualified name of <paramref name="existing"/>'s base, when resolvable.</param>
/// <param name="templateNameById">Template-id→name map for the composition targets.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareDerivedTemplate(
TemplateDto incoming,
string qualifiedName,
Template? existing,
string? existingBaseQualifiedName,
IReadOnlyDictionary<int, string>? templateNameById = null)
{
ArgumentNullException.ThrowIfNull(incoming);
ArgumentNullException.ThrowIfNull(qualifiedName);
if (existing is null)
{
return New("Template", qualifiedName);
}
var changes = new List<FieldChange>();
AddIfDifferent(changes, "IsDerived", existing.IsDerived, true);
AddIfDifferent(changes, "BaseTemplate", existingBaseQualifiedName, incoming.Derivation?.BaseQualifiedName());
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
var own = new Template(existing.Name)
{
Id = existing.Id,
Description = existing.Description,
Attributes = existing.Attributes.Where(a => !a.IsInherited).ToList(),
Alarms = existing.Alarms.Where(a => !a.IsInherited).ToList(),
Scripts = existing.Scripts.Where(x => !x.IsInherited).ToList(),
Compositions = existing.Compositions,
NativeAlarmSources = existing.NativeAlarmSources.Where(n => !n.IsInherited).ToList(),
};
// Alarm on-trigger links may point at an inherited script row, so resolve
// them over every script the target template holds.
CollectTemplateBodyChanges(incoming, own, existing, templateNameById, changes);
return BuildItem("Template", qualifiedName, changes);
}
private static void CollectTemplateBodyChanges(
TemplateDto incoming,
Template existing,
Template scriptSource,
IReadOnlyDictionary<int, string>? templateNameById,
List<FieldChange> changes)
{
// Bundles reference the alarm on-trigger script by name; resolve the
// persisted FK back to a name over this template's own scripts so the
// shared equality can compare it. See TemplateChildEquality.
var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts);
var scriptNameById = TemplateChildEquality.ScriptNameResolver(scriptSource.Scripts);
// Children: compare each child collection by name. We track which names
// were added, which were removed, and which existed on both sides but
@@ -127,8 +188,6 @@ public sealed class ArtifactDiff
TemplateChildEquality.NativeAlarmSourcesEqual,
"NativeAlarmSources",
changes);
return BuildItem("Template", incoming.Name, changes);
}
/// <summary>
@@ -0,0 +1,457 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// ScadaBridge#118 — derived (composition-slot) templates on import.
/// <para>
/// A bundle marks a derived template with <see cref="TemplateDto.Derivation"/> (owner path +
/// base path) and carries only its own rows. The ordinary template passes never see it —
/// they would create it as a base template named after its slot, which is the bug #118
/// fixed. Instead, once the base templates are applied and flushed, each derived template
/// is rebuilt through the same <see cref="TemplateService"/> compose flow the Central UI and
/// the management API use (<see cref="TemplateService.AddCompositionAsync"/>, which creates
/// the slot-owned derived template, its inherited placeholders and the nested slots of the
/// base), its own rows are applied as overrides, and
/// <see cref="TemplateService.ResyncInheritedMembersAsync"/> reconciles the placeholders.
/// A slot that already exists on the target with the same base is synced in place; one with
/// a different base is removed through <see cref="TemplateService.DeleteCompositionAsync"/>
/// and recomposed. An owner or base that resolves in neither the bundle nor the target
/// fails that one record — audited as <c>BundleImportDerivedTemplateUnresolved</c> and
/// reported on <see cref="ImportResult.Warnings"/> — and no base template is created in its place.
/// </para>
/// </summary>
public sealed partial class BundleImporter
{
private readonly record struct TemplateRow(int Id, string Name, bool IsDerived);
private readonly record struct PathResult(int? Id, string? Failure);
/// <summary>
/// Resolves a (root template, slot path) name path to a template id. The root follows the
/// ScadaBridge#100 reference rule — exact name, base template first; a differently-cased
/// base (the row an Overwrite lands on, ScadaBridge#93) is the fallback; a name held only by
/// derived templates is not a valid root and is reported, never guessed. Each slot is then
/// matched on the current template's compositions (exact, then case-insensitive).
/// </summary>
private static async Task<PathResult> ResolveTemplatePathAsync(
string root,
IReadOnlyList<string> slots,
Func<string, Task<IReadOnlyList<TemplateRow>>> templatesNamed,
Func<int, Task<IReadOnlyList<TemplateComposition>>> compositionsOf)
{
var rows = (await templatesNamed(root).ConfigureAwait(false))
.Where(r => string.Equals(r.Name, root, StringComparison.OrdinalIgnoreCase))
.ToList();
var bases = rows.Where(r => !r.IsDerived).ToList();
var exact = bases.Where(r => string.Equals(r.Name, root, StringComparison.Ordinal)).ToList();
int currentId;
if (exact.Count > 0)
{
currentId = exact.Min(r => r.Id);
}
else if (bases.Count == 1)
{
currentId = bases[0].Id;
}
else if (bases.Count > 1)
{
return new PathResult(null, $"base template name '{root}' matches several target templates by case only; refusing to guess.");
}
else if (rows.Count > 0)
{
return new PathResult(null,
$"'{root}' is held only by derived (composition-slot) templates in the target and by no base template; "
+ "a derivation root must be a base template (ScadaBridge#100).");
}
else
{
return new PathResult(null, $"base template '{root}' is present in neither the bundle nor the target.");
}
var path = root;
foreach (var slot in slots)
{
var comps = await compositionsOf(currentId).ConfigureAwait(false);
var match = comps.FirstOrDefault(c => string.Equals(c.InstanceName, slot, StringComparison.Ordinal));
if (match is null)
{
var ci = comps.Where(c => string.Equals(c.InstanceName, slot, StringComparison.OrdinalIgnoreCase)).ToList();
match = ci.Count == 1 ? ci[0] : null;
}
if (match is null)
{
return new PathResult(null, $"slot '{slot}' was not found under '{path}'.");
}
currentId = match.ComposedTemplateId;
path += "." + slot;
}
return new PathResult(currentId, null);
}
/// <summary>The persisted name of a bundled base template, honouring a Rename resolution.</summary>
private static string ImportedRootName(Dictionary<(string, string), ImportResolution> map, string root)
{
var resolution = ResolveOrDefault(map, "Template", root);
return resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
? resolution.RenameTo
: root;
}
/// <summary>
/// The top-level derived slots the bundle carries, per bundled owner name: the composition
/// rows the ordinary composition pass must neither create nor delete, because
/// <see cref="ApplyDerivedTemplatesAsync"/> owns them.
/// </summary>
private static Dictionary<string, HashSet<string>> DerivedSlotsByOwner(DerivedTemplatePlan plan)
{
var result = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var dto in plan.Ordered)
{
var d = dto.Derivation!;
if (d.SlotPath.Count != 1) continue;
if (!result.TryGetValue(d.OwnerTemplateName, out var slots))
{
slots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
result[d.OwnerTemplateName] = slots;
}
slots.Add(d.SlotPath[0]);
}
return result;
}
// ---- Preview ----
/// <summary>
/// One preview row per bundled derived template, keyed by its qualified name and compared
/// against the target's template at the same owner path (never against a base template
/// that shares the slot name).
/// </summary>
private async Task<IReadOnlyList<ImportPreviewItem>> PreviewDerivedTemplatesAsync(
IReadOnlyList<TemplateDto> derived,
TemplateLibrary library,
IReadOnlyDictionary<int, string> templateNameById,
CancellationToken ct)
{
if (derived.Count == 0) return Array.Empty<ImportPreviewItem>();
var analysis = await library.GetAnalysisAsync(ct).ConfigureAwait(false);
var byId = analysis.ToDictionary(t => t.Id);
var compositionById = analysis.SelectMany(t => t.Compositions)
.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First());
var rows = analysis.Select(t => new TemplateRow(t.Id, t.Name, t.IsDerived)).ToList();
Task<IReadOnlyList<TemplateRow>> Named(string _) => Task.FromResult<IReadOnlyList<TemplateRow>>(rows);
Task<IReadOnlyList<TemplateComposition>> CompositionsOf(int id) =>
Task.FromResult<IReadOnlyList<TemplateComposition>>(
byId.TryGetValue(id, out var t) ? t.Compositions.ToList() : new List<TemplateComposition>());
var existingIdByKey = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var dto in derived)
{
var d = dto.Derivation!;
if (d.SlotPath is not { Count: > 0 }) continue;
var found = await ResolveTemplatePathAsync(d.OwnerTemplateName, d.SlotPath, Named, CompositionsOf).ConfigureAwait(false);
if (found.Id is { } id) existingIdByKey[d.QualifiedName()] = id;
}
var hydrated = existingIdByKey.Count == 0
? new Dictionary<int, Template>()
: (await _templateRepo.GetTemplatesWithChildrenAsync(
existingIdByKey.Values.Where(byId.ContainsKey).Select(id => byId[id].Name), ct).ConfigureAwait(false))
.GroupBy(t => t.Id).ToDictionary(g => g.Key, g => g.First());
var items = new List<ImportPreviewItem>(derived.Count);
foreach (var dto in derived)
{
var key = DerivedTemplatePlan.Key(dto);
Template? existing = null;
string? existingBase = null;
if (existingIdByKey.TryGetValue(key, out var id) && hydrated.TryGetValue(id, out var full))
{
existing = full;
existingBase = byId.TryGetValue(id, out var row)
? EntitySerializer.TryBuildDerivation(row, byId, compositionById)?.BaseQualifiedName()
: null;
}
items.Add(_diff.CompareDerivedTemplate(dto, key, existing, existingBase, templateNameById));
}
return items;
}
// ---- Apply ----
/// <summary>
/// Rebuilds every bundled derived template through the compose flow, in
/// <see cref="DerivedTemplatePlan"/> order. Runs after the base templates are flushed and
/// the merged graph passed the acyclicity guard; <see cref="TemplateService"/> saves as it
/// goes, inside the import's transaction.
/// </summary>
/// <returns>The ids of the derived templates written (for the stale-instance probe) and
/// one warning per derived template that could not be imported.</returns>
private async Task<(IReadOnlyList<int> Touched, IReadOnlyList<string> Warnings)> ApplyDerivedTemplatesAsync(
DerivedTemplatePlan plan,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
var touched = new List<int>();
var warnings = new List<string>();
if (plan.Ordered.Count == 0) return (touched, warnings);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
var service = new TemplateService(_templateRepo, _auditService);
async Task<IReadOnlyList<TemplateRow>> Named(string name)
{
var lower = name.ToLower();
return await _dbContext.Templates.AsNoTracking()
.Where(t => t.Name.ToLower() == lower)
.Select(t => new TemplateRow(t.Id, t.Name, t.IsDerived))
.ToListAsync(ct).ConfigureAwait(false);
}
async Task<IReadOnlyList<TemplateComposition>> CompositionsOf(int id) =>
await _dbContext.TemplateCompositions.AsNoTracking()
.Where(c => c.TemplateId == id)
.ToListAsync(ct).ConfigureAwait(false);
foreach (var dto in plan.Ordered)
{
var d = dto.Derivation!;
var key = d.QualifiedName();
if (ResolveOrDefault(map, "Template", key).Action == ResolutionAction.Skip)
{
summary.Skipped++;
continue;
}
var ownerSlots = d.SlotPath.Take(d.SlotPath.Count - 1).ToList();
var ownerPath = TemplateDerivationDto.Qualify(d.OwnerTemplateName, ownerSlots);
var owner = await ResolveTemplatePathAsync(
ImportedRootName(map, d.OwnerTemplateName), ownerSlots, Named, CompositionsOf).ConfigureAwait(false);
if (owner.Id is not { } ownerId)
{
await FailDerivedAsync(dto, key, $"its owner '{ownerPath}' could not be resolved: {owner.Failure}").ConfigureAwait(false);
continue;
}
var baseResult = await ResolveTemplatePathAsync(
ImportedRootName(map, d.BaseTemplateName), d.BaseSlotPath, Named, CompositionsOf).ConfigureAwait(false);
if (baseResult.Id is not { } baseId)
{
await FailDerivedAsync(dto, key,
$"its base '{d.BaseQualifiedName()}' could not be resolved: {baseResult.Failure} "
+ "The slot was not created, and no base template was created in its place.").ConfigureAwait(false);
continue;
}
var slot = d.SlotPath[^1];
var ownerComps = await CompositionsOf(ownerId).ConfigureAwait(false);
var existingComp = ownerComps.FirstOrDefault(c => string.Equals(c.InstanceName, slot, StringComparison.Ordinal))
?? ownerComps.FirstOrDefault(c => string.Equals(c.InstanceName, slot, StringComparison.OrdinalIgnoreCase));
int? derivedId = null;
if (existingComp is not null)
{
var current = await _dbContext.Templates.AsNoTracking()
.Where(t => t.Id == existingComp.ComposedTemplateId)
.Select(t => new { t.Id, t.IsDerived, t.OwnerCompositionId, t.ParentTemplateId })
.FirstOrDefaultAsync(ct).ConfigureAwait(false);
if (current is not null && current.IsDerived
&& current.OwnerCompositionId == existingComp.Id && current.ParentTemplateId == baseId)
{
derivedId = current.Id;
}
else
{
// The slot exists but is not this base's derived template (another base, or a
// pre-derived composition straight onto a base): rebuild it.
var deleted = await service.DeleteCompositionAsync(existingComp.Id, user, ct).ConfigureAwait(false);
if (!deleted.IsSuccess)
{
await FailDerivedAsync(dto, key, $"the existing slot could not be replaced: {deleted.Error}").ConfigureAwait(false);
continue;
}
}
}
var composed = false;
if (derivedId is null)
{
var added = await service.AddCompositionAsync(ownerId, baseId, slot, user, ct).ConfigureAwait(false);
if (!added.IsSuccess)
{
await FailDerivedAsync(dto, key, $"the compose flow rejected it: {added.Error}").ConfigureAwait(false);
continue;
}
derivedId = added.Value.ComposedTemplateId;
composed = true;
}
await SyncDerivedOwnMembersAsync(derivedId.Value, dto, key, user, ct).ConfigureAwait(false);
var resync = await service.ResyncInheritedMembersAsync(derivedId.Value, user, ct).ConfigureAwait(false);
if (!resync.IsSuccess)
{
warnings.Add($"Derived template '{key}': inherited members were not resynced: {resync.Error}");
}
touched.Add(derivedId.Value);
if (composed) summary.Added++;
else summary.Overwritten++;
}
return (touched, warnings);
async Task FailDerivedAsync(TemplateDto dto, string key, string reason)
{
summary.Skipped++;
warnings.Add($"Derived template '{key}' was not imported: {reason}");
await _auditService.LogAsync(
user,
"BundleImportDerivedTemplateUnresolved",
"Template",
"0",
key,
new
{
DerivedTemplateName = key,
OwnerTemplateName = dto.Derivation!.OwnerTemplateName,
dto.Derivation.SlotPath,
BaseTemplateName = dto.Derivation.BaseQualifiedName(),
Reason = reason,
},
ct).ConfigureAwait(false);
}
}
/// <summary>
/// Applies a derived template's own bundled rows onto the composed (or existing) derived
/// template: each bundled row becomes an override (or an addition) with
/// <c>IsInherited = false</c>; an own row on the target that the bundle no longer carries
/// reverts to an inherited placeholder, which the following resync re-syncs to the base —
/// or removes, when the base has no such member.
/// </summary>
private async Task SyncDerivedOwnMembersAsync(int derivedId, TemplateDto dto, string key, string user, CancellationToken ct)
{
var derived = await _templateRepo.GetTemplateByIdAsync(derivedId, ct).ConfigureAwait(false)
?? throw new InvalidOperationException($"Derived template {derivedId} vanished during import.");
derived.Description = dto.Description;
var attrs = ToNameLookup(derived.Attributes, a => a.Name);
foreach (var a in dto.Attributes)
{
if (!attrs.TryGetValue(a.Name, out var row))
{
row = new TemplateAttribute(a.Name) { TemplateId = derived.Id };
derived.Attributes.Add(row);
}
row.Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType);
row.DataType = a.DataType;
row.ElementDataType = a.ElementDataType;
row.IsLocked = a.IsLocked;
row.Description = a.Description;
row.DataSourceReference = a.DataSourceReference;
row.LockedInDerived = a.LockedInDerived;
row.IsInherited = false;
}
RevertStale(derived.Attributes, dto.Attributes.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
var scripts = ToNameLookup(derived.Scripts, s => s.Name);
foreach (var s in dto.Scripts)
{
if (!scripts.TryGetValue(s.Name, out var row))
{
row = new TemplateScript(s.Name, s.Code) { TemplateId = derived.Id };
derived.Scripts.Add(row);
}
row.Code = s.Code;
row.TriggerType = s.TriggerType;
row.TriggerConfiguration = s.TriggerConfiguration;
row.ParameterDefinitions = s.ParameterDefinitions;
row.ReturnDefinition = s.ReturnDefinition;
row.IsLocked = s.IsLocked;
row.MinTimeBetweenRuns = s.MinTimeBetweenRuns;
row.ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds;
row.LockedInDerived = s.LockedInDerived;
row.IsInherited = false;
}
RevertStale(derived.Scripts, dto.Scripts.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
var alarms = ToNameLookup(derived.Alarms, a => a.Name);
var ownAlarms = new List<(TemplateAlarm Row, string? Script)>();
foreach (var a in dto.Alarms)
{
if (!alarms.TryGetValue(a.Name, out var row))
{
row = new TemplateAlarm(a.Name) { TemplateId = derived.Id };
derived.Alarms.Add(row);
}
row.Description = a.Description;
row.PriorityLevel = a.PriorityLevel;
row.TriggerType = a.TriggerType;
row.TriggerConfiguration = a.TriggerConfiguration;
row.IsLocked = a.IsLocked;
row.LockedInDerived = a.LockedInDerived;
row.IsMesReportable = a.IsMesReportable;
row.IsInherited = false;
ownAlarms.Add((row, a.OnTriggerScriptName));
}
RevertStale(derived.Alarms, dto.Alarms.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
var sources = ToNameLookup(derived.NativeAlarmSources, n => n.Name);
foreach (var n in dto.NativeAlarmSources)
{
if (!sources.TryGetValue(n.Name, out var row))
{
row = new TemplateNativeAlarmSource(n.Name) { TemplateId = derived.Id };
derived.NativeAlarmSources.Add(row);
}
row.Description = n.Description;
row.ConnectionName = n.ConnectionName;
row.SourceReference = n.SourceReference;
row.ConditionFilter = n.ConditionFilter;
row.IsMesReportable = n.IsMesReportable;
row.IsLocked = n.IsLocked;
row.LockedInDerived = n.LockedInDerived;
row.IsInherited = false;
}
RevertStale(derived.NativeAlarmSources, dto.NativeAlarmSources.Select(x => x.Name), x => x.Name, x => x.IsInherited = true, x => x.IsInherited);
// New script rows need ids before an alarm can point at them.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
var scriptIdByName = ToNameLookup(derived.Scripts, s => s.Name);
foreach (var (row, script) in ownAlarms)
{
row.OnTriggerScriptId = script is not null && scriptIdByName.TryGetValue(script, out var s) ? s.Id : null;
}
await _auditService.LogAsync(user, "Update", "Template", derived.Id.ToString(), key,
new
{
QualifiedName = key,
BaseTemplateName = dto.Derivation!.BaseQualifiedName(),
OwnAttributes = dto.Attributes.Count,
OwnAlarms = dto.Alarms.Count,
OwnScripts = dto.Scripts.Count,
OwnNativeAlarmSources = dto.NativeAlarmSources.Count,
},
ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
static void RevertStale<T>(
IEnumerable<T> rows, IEnumerable<string> bundled, Func<T, string> name, Action<T> revert, Func<T, bool> isInherited)
{
var keep = new HashSet<string>(bundled, StringComparer.OrdinalIgnoreCase);
foreach (var row in rows.Where(r => !isInherited(r) && !keep.Contains(name(r))).ToList())
{
revert(row);
}
}
}
}
@@ -56,7 +56,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// avoid double-logging.
/// </para>
/// </summary>
public sealed class BundleImporter : IBundleImporter
public sealed partial class BundleImporter : IBundleImporter
{
// All bundle content deserialization goes through BundleJsonOptions.Default.
// IMPORTANT — unknown JSON properties must remain ALLOWED (the default
@@ -513,7 +513,10 @@ public sealed class BundleImporter : IBundleImporter
// and called GetTemplateWithChildrenAsync(stub.Id) once per matching
// name (classic N+1). The bulk variant fetches every matching template
// with children eager-loaded in a single round-trip.
var bundleTemplateNames = content.Templates.Select(t => t.Name);
// ScadaBridge#118: a bundled DERIVED template is compared against the target's
// template at its owner path, under its qualified name, never by its bare slot name.
var baseTemplateDtos = content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)).ToList();
var bundleTemplateNames = baseTemplateDtos.Select(t => t.Name);
// Base templates only: a derived template carries its composition slot name and is
// never what a bundled template of that name targets (ScadaBridge#95).
var hydratedTemplates = await _templateRepo
@@ -521,11 +524,13 @@ public sealed class BundleImporter : IBundleImporter
.ConfigureAwait(false);
var hydratedByName = ToTemplateLookup(hydratedTemplates);
var templateNameById = await library.GetNameByIdAsync(ct).ConfigureAwait(false);
foreach (var tDto in content.Templates)
foreach (var tDto in baseTemplateDtos)
{
hydratedByName.TryGetValue(tDto.Name, out var existing);
items.Add(_diff.CompareTemplate(tDto, existing, folderNameById, templateNameById));
}
items.AddRange(await PreviewDerivedTemplatesAsync(
content.Templates.Where(DerivedTemplatePlan.IsDerived).ToList(), library, templateNameById, ct).ConfigureAwait(false));
// ---- SharedScripts ----
// One read of each library (04/P4); DetectBlockersAsync reuses the same lists
@@ -867,7 +872,20 @@ public sealed class BundleImporter : IBundleImporter
// ∪ (templates already in the target DB). An instance whose TemplateName is
// in neither is unresolvable.
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates) bundleTemplateNames.Add(t.Name);
// A derived slot template is not something an instance is created from (#118).
foreach (var t in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t))) bundleTemplateNames.Add(t.Name);
// ScadaBridge#118: a malformed or cyclic derivation fails the apply outright.
foreach (var error in DerivedTemplatePlan.Build(content.Templates).Errors)
{
blockers.Add(new ImportPreviewItem(
EntityType: "Template",
Name: "(derived templates)",
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: error));
}
// Honour a rename: a bundled template resolved as Rename is created under
// its new name, but at preview time no resolution has been chosen yet, so
// the in-bundle name is the original DTO name — which is what an instance
@@ -1136,7 +1154,7 @@ public sealed class BundleImporter : IBundleImporter
foreach (var t in content.Templates)
{
if (IsSkipResolution(resolutionMap, "Template", t.Name)) continue;
if (IsSkipResolution(resolutionMap, "Template", DerivedTemplatePlan.Key(t))) continue;
foreach (var s in t.Scripts) CheckMember(t, "Script", s.Name);
foreach (var a in t.Alarms) CheckMember(t, "Alarm", a.Name);
foreach (var c in t.Compositions) CheckMember(t, "Composition slot", c.InstanceName);
@@ -1311,12 +1329,12 @@ public sealed class BundleImporter : IBundleImporter
{
foreach (var t in content.Templates)
{
if (IsSkipResolution(resolutionMap, "Template", t.Name)) continue;
if (IsSkipResolution(resolutionMap, "Template", DerivedTemplatePlan.Key(t))) continue;
foreach (var s in t.Scripts)
{
if (!string.IsNullOrEmpty(s.Code))
{
yield return ("Template", t.Name, s.Name, s.Code);
yield return ("Template", DerivedTemplatePlan.Key(t), s.Name, s.Code);
}
var scriptExpr = ExtractTriggerExpression(s.TriggerType, s.TriggerConfiguration);
if (!string.IsNullOrEmpty(scriptExpr))
@@ -1465,7 +1483,7 @@ public sealed class BundleImporter : IBundleImporter
var warnings = new List<(string, string)>();
var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
foreach (var t in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t))) bundleTemplates[t.Name] = t;
var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
foreach (var t in targetTemplates) targetByName[t.Name] = t;
@@ -1848,9 +1866,13 @@ public sealed class BundleImporter : IBundleImporter
// trusts blindly — site gRPC endpoints and names that become actor names —
// are rejected here, with the change tracker still empty, rather than
// surfacing as a channel-build throw on central or an ActorOf throw at deploy.
// ScadaBridge#118: derived templates apply in owner/base order; a malformed or
// cyclic derivation is a hard error raised here, with nothing staged.
var derivedPlan = DerivedTemplatePlan.Build(content.Templates);
var ruleErrors = CollectActorNameViolations(content, resolutionMap)
.Select(v => v.Message)
.Concat(await CollectSiteEndpointErrorsAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false))
.Concat(derivedPlan.Errors)
.ToList();
if (ruleErrors.Count > 0 || validationErrors.Count > 0)
{
@@ -1897,7 +1919,11 @@ public sealed class BundleImporter : IBundleImporter
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
// ScadaBridge#118: the ordinary template passes see base templates only; a derived
// (composition-slot) template is rebuilt by ApplyDerivedTemplatesAsync below. A pre-#118
// bundle carries no derivations, so this is every template, in bundle order.
var baseTemplateDtos = content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)).ToList();
await ApplyTemplatesAsync(baseTemplateDtos, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
@@ -1924,9 +1950,10 @@ public sealed class BundleImporter : IBundleImporter
// remaining failure surface is the final BundleImported audit
// write itself.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, library, user, ct).ConfigureAwait(false);
await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, library, user, ct).ConfigureAwait(false);
await ResolveAlarmScriptLinksAsync(baseTemplateDtos, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(
baseTemplateDtos, resolutionMap, library, DerivedSlotsByOwner(derivedPlan), user, ct).ConfigureAwait(false);
await ResolveInheritanceEdgesAsync(baseTemplateDtos, resolutionMap, library, user, ct).ConfigureAwait(false);
// Folder parent edges are name-keyed too — resolve them AFTER the flush
// above so every imported folder has a materialised id to point at
// (the Add pass created the folders parent-less). Mirrors the template
@@ -1951,6 +1978,12 @@ public sealed class BundleImporter : IBundleImporter
// SaveChanges precedes this check so nothing survives that rollback.
EnsureNoTemplateGraphCycles(await library.GetAnalysisAsync(ct).ConfigureAwait(false));
// ---- Derived (composition-slot) templates, through the compose flow (#118) ----
// After the guard, so nothing it saves precedes the cycle check; the compose flow
// runs its own composition / cross-graph cycle and collision checks per slot.
var (derivedTouched, derivedWarnings) = await ApplyDerivedTemplatesAsync(
derivedPlan, resolutionMap, user, summary, ct).ConfigureAwait(false);
// ---- Site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
// • template id — resolved by name against the just-flushed central
@@ -1976,7 +2009,7 @@ public sealed class BundleImporter : IBundleImporter
// is the next statement, so a read against the change tracker here sees
// the full post-apply graph before commit.
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
content, resolutionMap, library, ct).ConfigureAwait(false);
content, resolutionMap, library, derivedTouched, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user: user,
@@ -2017,7 +2050,7 @@ public sealed class BundleImporter : IBundleImporter
StaleInstanceIds: staleInstanceIds,
AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored,
Warnings: validationWarnings);
Warnings: derivedWarnings.Count == 0 ? validationWarnings : validationWarnings.Concat(derivedWarnings).ToList());
}
/// <summary>
@@ -2151,6 +2184,7 @@ public sealed class BundleImporter : IBundleImporter
BundleContentDto content,
Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap,
TemplateLibrary library,
IReadOnlyList<int> derivedTemplateIds,
CancellationToken ct)
{
// No flattening pipeline registered → can't recompute hashes. Staleness is
@@ -2167,8 +2201,10 @@ public sealed class BundleImporter : IBundleImporter
// to their (unchanged) target ids.
var targetTemplateIdByName = await library.GetIdByNameAsync(ct).ConfigureAwait(false);
var overwrittenTemplateIds = new HashSet<int>();
foreach (var dto in content.Templates)
// ScadaBridge#118: every derived slot template the import (re)built changes what its
// owners flatten to, so it seeds the expansion like an overwritten template.
var overwrittenTemplateIds = new HashSet<int>(derivedTemplateIds);
foreach (var dto in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)))
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action != ResolutionAction.Overwrite) continue;
@@ -3294,6 +3330,7 @@ public sealed class BundleImporter : IBundleImporter
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
TemplateLibrary library,
IReadOnlyDictionary<string, HashSet<string>> derivedSlotsByOwner,
string user,
CancellationToken ct)
{
@@ -3326,22 +3363,38 @@ public sealed class BundleImporter : IBundleImporter
{
if (!loadedById.TryGetValue(parentStub.Id, out var parent)) continue;
// ScadaBridge#118: the slots this owner's bundled DERIVED templates fill are
// composed (or kept and synced) by ApplyDerivedTemplatesAsync through the compose
// flow, so this pass neither deletes nor writes those rows. Empty for a pre-#118
// bundle, which keeps the clear-and-rewrite below exactly as it was.
derivedSlotsByOwner.TryGetValue(dto.Name, out var derivedSlots);
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
// the deletes via the navigation; explicit DeleteAsync ensures the
// change is staged even when the relational provider doesn't
// detect orphans automatically.
if (parent.Compositions.Count > 0)
{
var existingComps = parent.Compositions.ToList();
var existingComps = parent.Compositions
.Where(c => derivedSlots is null || !derivedSlots.Contains(c.InstanceName))
.ToList();
foreach (var existing in existingComps)
{
await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
}
parent.Compositions.Clear();
if (derivedSlots is null)
{
parent.Compositions.Clear();
}
else
{
foreach (var existing in existingComps) parent.Compositions.Remove(existing);
}
}
foreach (var compDto in dto.Compositions)
{
if (derivedSlots is not null && derivedSlots.Contains(compDto.InstanceName)) continue;
if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
{
await _auditService.LogAsync(
@@ -5337,7 +5390,7 @@ public sealed class BundleImporter : IBundleImporter
// ---- Instance template references ----
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates)
foreach (var t in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)))
{
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
@@ -5502,7 +5555,7 @@ public sealed class BundleImporter : IBundleImporter
{
// Skip-resolved templates aren't being written, so their script
// references don't need to resolve.
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
var resolution = ResolveOrDefault(resolutionMap, "Template", DerivedTemplatePlan.Key(t));
if (resolution.Action == ResolutionAction.Skip) continue;
foreach (var s in t.Scripts)
{
@@ -5600,7 +5653,12 @@ public sealed class BundleImporter : IBundleImporter
});
}
foreach (var dto in content.Templates)
// ScadaBridge#118: a derived template carries only its own rows (its inherited members
// come from the base at compose time), so a one-template flatten of the DTO would
// report every reference to an inherited member as missing. The compose flow and the
// deploy-time flatten validate it against the full graph; the trust gate above
// still covers its script bodies.
foreach (var dto in content.Templates.Where(t => !DerivedTemplatePlan.IsDerived(t)))
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
@@ -0,0 +1,127 @@
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// ScadaBridge#118 — the apply order of a bundle's derived (composition-slot) templates.
/// <para>
/// A derived template is rebuilt by composing its base under its owner, so it can only be
/// applied once both exist on the target. Base templates are applied by the ordinary
/// template pass first; between derived templates the dependencies are the owner (a nested
/// slot's owner is itself a derived template) and the base (a cascade-created nested slot
/// derives from the source-side derived template). <see cref="Build"/> orders the derived
/// templates topologically over those edges — the bundle's own order is irrelevant, so a
/// hand-edited bundle that lists a derived template before its base still applies — and
/// reports a cycle or a malformed derivation as an error, which the importer raises before
/// anything is written.
/// </para>
/// </summary>
internal sealed class DerivedTemplatePlan
{
private DerivedTemplatePlan(IReadOnlyList<TemplateDto> ordered, IReadOnlyList<string> errors)
{
Ordered = ordered;
Errors = errors;
}
/// <summary>The derived templates in apply order (owners and bases first).</summary>
public IReadOnlyList<TemplateDto> Ordered { get; }
/// <summary>Malformed derivations, duplicate identities and cycles — each a hard import error.</summary>
public IReadOnlyList<string> Errors { get; }
/// <summary>
/// The identity a bundled template is previewed and resolved under: its name for a base
/// template, its qualified owner path (<c>Owner.Slot…</c>) for a derived one — a derived
/// template's own name is only its slot name and is shared by every slot of that name.
/// </summary>
/// <param name="dto">The bundled template.</param>
/// <returns>The preview / resolution key.</returns>
public static string Key(TemplateDto dto) => dto.Derivation?.QualifiedName() ?? dto.Name;
/// <summary>Whether <paramref name="dto"/> is a derived (composition-slot) template.</summary>
/// <param name="dto">The bundled template.</param>
/// <returns><c>true</c> when it carries a derivation.</returns>
public static bool IsDerived(TemplateDto dto) => dto.Derivation is not null;
/// <summary>Builds the plan over a bundle's templates (base templates are ignored).</summary>
/// <param name="templates">Every bundled template.</param>
/// <returns>The ordered derived templates plus any errors.</returns>
public static DerivedTemplatePlan Build(IEnumerable<TemplateDto> templates)
{
var errors = new List<string>();
var byKey = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
foreach (var dto in templates.Where(IsDerived))
{
var d = dto.Derivation!;
if (string.IsNullOrEmpty(d.OwnerTemplateName)
|| d.SlotPath is null || d.SlotPath.Count == 0 || d.SlotPath.Any(string.IsNullOrEmpty)
|| string.IsNullOrEmpty(d.BaseTemplateName)
|| d.BaseSlotPath is null || d.BaseSlotPath.Any(string.IsNullOrEmpty))
{
errors.Add($"Derived template '{dto.Name}' carries a malformed derivation (owner, slot path and base are all required).");
continue;
}
var key = d.QualifiedName();
if (!byKey.TryAdd(key, dto))
{
errors.Add($"Derived template '{key}' appears more than once in the bundle.");
}
}
// Edges: dependency → dependant, for dependencies that are themselves bundled
// derived templates (an owner or base outside this set is resolved on apply).
var inDegree = byKey.Keys.ToDictionary(k => k, _ => 0, StringComparer.Ordinal);
var dependants = byKey.Keys.ToDictionary(k => k, _ => new List<string>(), StringComparer.Ordinal);
foreach (var (key, dto) in byKey)
{
var d = dto.Derivation!;
var deps = new HashSet<string>(StringComparer.Ordinal);
if (d.SlotPath.Count > 1)
deps.Add(TemplateDerivationDto.Qualify(d.OwnerTemplateName, d.SlotPath.Take(d.SlotPath.Count - 1).ToList()));
if (d.BaseSlotPath.Count > 0)
deps.Add(d.BaseQualifiedName());
foreach (var dep in deps)
{
if (dep == key || !byKey.ContainsKey(dep))
{
if (dep == key)
{
// Self-dependency is a one-node cycle.
inDegree[key]++;
}
continue;
}
dependants[dep].Add(key);
inDegree[key]++;
}
}
var queue = new Queue<string>(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)
.OrderBy(k => k, StringComparer.Ordinal));
var ordered = new List<TemplateDto>(byKey.Count);
while (queue.Count > 0)
{
var key = queue.Dequeue();
ordered.Add(byKey[key]);
foreach (var next in dependants[key].OrderBy(k => k, StringComparer.Ordinal))
{
if (--inDegree[next] == 0) queue.Enqueue(next);
}
}
if (ordered.Count != byKey.Count)
{
var cyclic = inDegree.Where(kv => kv.Value > 0).Select(kv => kv.Key)
.OrderBy(k => k, StringComparer.Ordinal);
errors.Add(
"Derived templates cannot be ordered — their owner/base references form a cycle: "
+ string.Join(", ", cyclic) + ".");
}
return new DerivedTemplatePlan(ordered, errors);
}
}
@@ -176,6 +176,57 @@ public sealed record TemplateDto(
/// </summary>
public IReadOnlyList<TemplateNativeAlarmSourceDto> NativeAlarmSources { get; init; } =
Array.Empty<TemplateNativeAlarmSourceDto>();
// ScadaBridge#118: set only on a DERIVED (composition-slot) template. Init-only with a
// null default for the same forward/source-compat reasons as NativeAlarmSources, and
// omitted from the JSON when null (BundleJsonOptions WhenWritingNull), so a base
// template's wire shape is byte-identical to a pre-#118 bundle. A bundle written before
// #118 has no Derivation anywhere and imports exactly as it always did. When set, the
// member lists carry only the derived template's OWN rows (overrides + additions,
// IsInherited == false); the inherited placeholders are rebuilt on import by the normal
// compose flow from the base.
/// <summary>
/// The derived (composition-slot) template's place in the composition graph, or
/// <c>null</c> for a base template (and for every template of a pre-#118 bundle).
/// </summary>
public TemplateDerivationDto? Derivation { get; init; }
}
/// <summary>
/// ScadaBridge#118 — where a derived (composition-slot) template sits, by stable name
/// paths instead of ids. A derived template's own <see cref="TemplateDto.Name"/> is its
/// slot name, unique only within its owner, so it is identified by its owner path: the
/// base template at the root of the owner chain plus the slot names from it down to this
/// template (<see cref="SlotPath"/>, whose last element is this template's slot). The base
/// it derives from is addressed the same way: a base template name plus an empty
/// <see cref="BaseSlotPath"/>, or — for the nested slots the compose cascade creates — the
/// source-side derived template under that base.
/// </summary>
/// <param name="OwnerTemplateName">The base template at the root of the owner chain.</param>
/// <param name="SlotPath">Slot instance names from <paramref name="OwnerTemplateName"/> down to this template.</param>
/// <param name="BaseTemplateName">The base template at the root of the inherited-from path.</param>
/// <param name="BaseSlotPath">Slot names under <paramref name="BaseTemplateName"/>; empty when the base is a base template.</param>
public sealed record TemplateDerivationDto(
string OwnerTemplateName,
IReadOnlyList<string> SlotPath,
string BaseTemplateName,
IReadOnlyList<string> BaseSlotPath)
{
/// <summary>The dotted qualified name of the derived template (<c>Owner.Slot…</c>) —
/// its identity in the import preview and resolutions.</summary>
/// <returns>The qualified name.</returns>
public string QualifiedName() => Qualify(OwnerTemplateName, SlotPath);
/// <summary>The dotted qualified name of the base the template derives from.</summary>
/// <returns>The qualified base name.</returns>
public string BaseQualifiedName() => Qualify(BaseTemplateName, BaseSlotPath);
/// <summary>Joins a root template name and a slot path with dots.</summary>
/// <param name="root">The root template name.</param>
/// <param name="slots">The slot path below it.</param>
/// <returns><paramref name="root"/> when the path is empty, else <c>root.slot.slot…</c>.</returns>
public static string Qualify(string root, IReadOnlyList<string> slots) =>
slots.Count == 0 ? root : root + "." + string.Join('.', slots);
}
/// <summary>
@@ -28,6 +28,11 @@ public sealed class EntitySerializer
var folderNameById = aggregate.TemplateFolders.ToDictionary(f => f.Id, f => f.Name);
var templateNameById = aggregate.Templates.ToDictionary(t => t.Id, t => t.Name);
var templateById = aggregate.Templates.ToDictionary(t => t.Id);
var compositionById = aggregate.Templates
.SelectMany(t => t.Compositions)
.GroupBy(c => c.Id)
.ToDictionary(g => g.Key, g => g.First());
// Name-link lookups. Instances/data-connections reference their owning
// site by the portable SiteIdentifier (not the EF surrogate SiteId); a
@@ -49,12 +54,20 @@ public sealed class EntitySerializer
// (TemplateAlarm.OnTriggerScriptId FK is scoped to TemplateId),
// so we don't need a global script index.
var scriptNameById = t.Scripts.ToDictionary(s => s.Id, s => s.Name);
// ScadaBridge#118: a derived (composition-slot) template travels as its
// derivation plus its OWN rows only — the inherited placeholders are the
// base's members, which the importer's compose flow re-materializes. A
// derivation that cannot be resolved inside this aggregate (owner or base
// missing) falls back to the pre-#118 shape rather than emitting a dangling path.
var derivation = TryBuildDerivation(t, templateById, compositionById);
bool Own(bool isInherited) => derivation is null || !isInherited;
return new TemplateDto(
Name: t.Name,
FolderName: t.FolderId is { } fid && folderNameById.TryGetValue(fid, out var fname) ? fname : null,
BaseTemplateName: t.ParentTemplateId is { } btid && templateNameById.TryGetValue(btid, out var bname) ? bname : null,
Description: t.Description,
Attributes: t.Attributes.Select(a => new TemplateAttributeDto(
Attributes: t.Attributes.Where(a => Own(a.IsInherited)).Select(a => new TemplateAttributeDto(
Name: a.Name,
Value: a.Value,
DataType: a.DataType,
@@ -63,7 +76,7 @@ public sealed class EntitySerializer
DataSourceReference: a.DataSourceReference,
ElementDataType: a.ElementDataType,
LockedInDerived: a.LockedInDerived)).ToList(),
Alarms: t.Alarms.Select(a => new TemplateAlarmDto(
Alarms: t.Alarms.Where(a => Own(a.IsInherited)).Select(a => new TemplateAlarmDto(
Name: a.Name,
Description: a.Description,
PriorityLevel: a.PriorityLevel,
@@ -79,7 +92,7 @@ public sealed class EntitySerializer
OnTriggerScriptName: a.OnTriggerScriptId is { } sid && scriptNameById.TryGetValue(sid, out var sn) ? sn : null,
LockedInDerived: a.LockedInDerived,
IsMesReportable: a.IsMesReportable)).ToList(),
Scripts: t.Scripts.Select(s => new TemplateScriptDto(
Scripts: t.Scripts.Where(s => Own(s.IsInherited)).Select(s => new TemplateScriptDto(
Name: s.Name,
Code: s.Code,
TriggerType: s.TriggerType,
@@ -95,8 +108,10 @@ public sealed class EntitySerializer
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(
// placeholders — the collision detector depends on them — except
// on a template that travels with a Derivation (#118), whose
// placeholders the importer's compose flow rebuilds from the base.
NativeAlarmSources = t.NativeAlarmSources.Where(n => Own(n.IsInherited)).Select(n => new TemplateNativeAlarmSourceDto(
Name: n.Name,
Description: n.Description,
ConnectionName: n.ConnectionName,
@@ -106,6 +121,7 @@ public sealed class EntitySerializer
IsInherited: n.IsInherited,
LockedInDerived: n.LockedInDerived,
IsMesReportable: n.IsMesReportable)).ToList(),
Derivation = derivation,
};
}).ToList(),
SharedScripts: aggregate.SharedScripts.Select(s => new SharedScriptDto(
@@ -307,6 +323,61 @@ public sealed class EntitySerializer
};
}
/// <summary>
/// ScadaBridge#118: the derivation of a derived (composition-slot) template — its owner
/// path and its base path — or <c>null</c> for a base template, or when the owner or
/// base chain leaves the aggregate (the template then travels in the pre-#118 shape).
/// </summary>
internal static TemplateDerivationDto? TryBuildDerivation(
Template template,
IReadOnlyDictionary<int, Template> templateById,
IReadOnlyDictionary<int, TemplateComposition> compositionById)
{
if (!template.IsDerived || template.ParentTemplateId is not { } baseId) return null;
if (!templateById.TryGetValue(baseId, out var baseTemplate)) return null;
var owner = TryBuildPath(template, templateById, compositionById);
if (owner is null) return null;
(string Root, List<string> Slots)? basePath = baseTemplate.IsDerived
? TryBuildPath(baseTemplate, templateById, compositionById)
: (baseTemplate.Name, new List<string>());
if (basePath is null) return null;
return new TemplateDerivationDto(
OwnerTemplateName: owner.Value.Root,
SlotPath: owner.Value.Slots,
BaseTemplateName: basePath.Value.Root,
BaseSlotPath: basePath.Value.Slots);
}
// Walks a derived template's OwnerCompositionId chain up to the base template at its
// root: (root name, slot names root→template). Null when a link leaves the aggregate
// or the chain loops (the composition graph is acyclic by the save-gate; defensive).
private static (string Root, List<string> Slots)? TryBuildPath(
Template template,
IReadOnlyDictionary<int, Template> templateById,
IReadOnlyDictionary<int, TemplateComposition> compositionById)
{
var slots = new List<string>();
var visited = new HashSet<int>();
var current = template;
while (current.IsDerived)
{
if (!visited.Add(current.Id)
|| current.OwnerCompositionId is not { } compId
|| !compositionById.TryGetValue(compId, out var composition)
|| composition.ComposedTemplateId != current.Id
|| !templateById.TryGetValue(composition.TemplateId, out var owner))
{
return null;
}
slots.Insert(0, composition.InstanceName);
current = owner;
}
return (current.Name, slots);
}
/// <summary>Reconstructs a persistence-shaped <see cref="EntityAggregate"/> from a wire-shaped <see cref="BundleContentDto"/>.</summary>
/// <param name="content">The bundle content DTO to deserialize.</param>
/// <returns>An <see cref="EntityAggregate"/> with entities linked by name, ready for import conflict resolution.</returns>
@@ -17,7 +17,10 @@ public sealed class ManifestBuilder
// site/dataConnection/instance summary counts (and their content arrays).
// Minor bumps are additive-only: ManifestValidator gates solely on
// BundleFormatVersion, so an older "1.0" bundle still validates Ok.
public const string CurrentSchemaVersion = "1.1";
// 1.1 → 1.2 (ScadaBridge#118): TemplateDto.Derivation marks derived composition-slot
// templates. Additive — an older importer ignores the field (and imports the slot as
// it always did), a 1.0/1.1 bundle has no derivations and imports unchanged.
public const string CurrentSchemaVersion = "1.2";
/// <summary>
/// Builds a <see cref="BundleManifest"/> with the current format version, a SHA-256 content hash,
@@ -0,0 +1,678 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
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.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;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
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.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// ScadaBridge#118 — derived (composition-slot) templates travel as derived templates: the
/// bundle carries each one's owner path, base path and own rows, and the importer rebuilds
/// the slot through the TemplateService compose flow instead of creating a base template
/// named after the slot. Driven through the real export → load → preview → apply pipeline.
/// </summary>
public sealed class DerivedTemplateTransportTests : IDisposable
{
private readonly ServiceProvider _provider;
/// <summary>Initializes the test class with an in-memory database and the Transport services.</summary>
public DerivedTemplateTransportTests() => _provider = BuildProvider(new Dictionary<string, string?>());
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _provider.Dispose();
private static ServiceProvider BuildProvider(IDictionary<string, string?> settings)
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
var dbName = $"DerivedTemplateTransportTests_{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.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
return services.BuildServiceProvider();
}
// ── seeding through the real compose flow ───────────────────────────────────
private static async Task<T> WithServiceAsync<T>(ServiceProvider provider, Func<TemplateService, ScadaBridgeDbContext, Task<T>> body)
{
await using var scope = provider.CreateAsyncScope();
var svc = new TemplateService(
scope.ServiceProvider.GetRequiredService<ITemplateEngineRepository>(),
scope.ServiceProvider.GetRequiredService<IAuditService>());
return await body(svc, scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>());
}
private static T Ok<T>(Result<T> result)
{
Assert.True(result.IsSuccess, result.IsSuccess ? null : result.Error);
return result.Value;
}
/// <summary>
/// Motor (Speed=1, Mode="a", script Run, alarm High → Run) and Pump composing Motor as
/// slot M1; on the slot, Speed is overridden to 5 and an own attribute Extra is added.
/// Returns the derived slot template's id.
/// </summary>
private static Task<int> SeedPumpWithDerivedMotorAsync(ServiceProvider provider) =>
WithServiceAsync(provider, async (svc, ctx) =>
{
var motor = Ok(await svc.CreateTemplateAsync("Motor", "motor base", null, "seed"));
Ok(await svc.AddAttributeAsync(motor.Id, new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "1" }, "seed"));
Ok(await svc.AddAttributeAsync(motor.Id, new TemplateAttribute("Mode") { DataType = DataType.String, Value = "a" }, "seed"));
var run = Ok(await svc.AddScriptAsync(motor.Id, new TemplateScript("Run", "return 1;") { TriggerType = "Startup" }, "seed"));
Ok(await svc.AddAlarmAsync(motor.Id, new TemplateAlarm("High")
{
PriorityLevel = 500,
TriggerType = AlarmTriggerType.ValueMatch,
TriggerConfiguration = "{\"attribute\":\"Speed\",\"matchValue\":\"9\"}",
OnTriggerScriptId = run.Id,
}, "seed"));
var pump = Ok(await svc.CreateTemplateAsync("Pump", "pump", null, "seed"));
var slot = Ok(await svc.AddCompositionAsync(pump.Id, motor.Id, "M1", "seed"));
var derivedId = slot.ComposedTemplateId;
Ok(await svc.AddAttributeAsync(derivedId,
new TemplateAttribute("Extra") { DataType = DataType.String, Value = "x" }, "seed"));
var speed = await ctx.TemplateAttributes.AsNoTracking().SingleAsync(a => a.TemplateId == derivedId && a.Name == "Speed");
Ok(await svc.UpdateAttributeAsync(speed.Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "5", IsInherited = false }, "seed"));
return derivedId;
});
// ── export / load / preview / apply ─────────────────────────────────────────
private static async Task<byte[]> ExportAsync(ServiceProvider provider, string? passphrase = null, params string[] baseTemplateNames)
{
await using var scope = provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var ids = await ctx.Templates.Where(t => !t.IsDerived && (baseTemplateNames.Length == 0 || baseTemplateNames.Contains(t.Name)))
.Select(t => t.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: ids,
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: true);
var stream = await scope.ServiceProvider.GetRequiredService<IBundleExporter>()
.ExportAsync(selection, "alice", "dev", passphrase);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
return ms.ToArray();
}
private static async Task WipeTemplatesAsync(ServiceProvider provider)
{
await using var scope = provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
ctx.TemplateAttributes.RemoveRange(ctx.TemplateAttributes);
ctx.TemplateAlarms.RemoveRange(ctx.TemplateAlarms);
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.Templates.RemoveRange(ctx.Templates);
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
await ctx.SaveChangesAsync();
}
private static async Task<BundleSession> LoadAsync(ServiceProvider provider, byte[] bundle, string? passphrase = null)
{
await using var scope = provider.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
.LoadAsync(new MemoryStream(bundle, writable: false), passphrase);
}
private static async Task<ImportPreview> PreviewAsync(ServiceProvider provider, Guid sessionId)
{
await using var scope = provider.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>().PreviewAsync(sessionId);
}
/// <summary>Applies with the Central UI's default mapping (New→Add, Modified→Overwrite, Identical→Skip).</summary>
private static async Task<ImportResult> ApplyDefaultsAsync(
ServiceProvider provider, Guid sessionId, ImportPreview preview, params ImportResolution[] overrides)
{
var map = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Modified => ResolutionAction.Overwrite,
_ => ResolutionAction.Skip,
};
map[(item.EntityType, item.Name)] = new ImportResolution(item.EntityType, item.Name, action, null);
}
foreach (var o in overrides) map[(o.EntityType, o.Name)] = o;
await using var scope = provider.CreateAsyncScope();
return await scope.ServiceProvider.GetRequiredService<IBundleImporter>()
.ApplyAsync(sessionId, map.Values.ToList(), user: "bob");
}
private static async Task<ImportResult> RoundTripAsync(ServiceProvider provider, byte[] bundle, params ImportResolution[] overrides)
{
var session = await LoadAsync(provider, bundle);
var preview = await PreviewAsync(provider, session.SessionId);
Assert.DoesNotContain(preview.Items, i => i.Kind == ConflictKind.Blocker);
return await ApplyDefaultsAsync(provider, session.SessionId, preview, overrides);
}
private static async Task<Template> LoadSlotAsync(ScadaBridgeDbContext ctx, string owner, string slot)
{
var ownerT = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == owner && !t.IsDerived);
var comp = Assert.Single(ownerT.Compositions, c => c.InstanceName == slot);
return await ctx.Templates
.Include(t => t.Attributes).Include(t => t.Alarms).Include(t => t.Scripts).Include(t => t.Compositions)
.SingleAsync(t => t.Id == comp.ComposedTemplateId);
}
// ── bundle surgery (hand-edited / crafted bundles) ──────────────────────────
private static BundleContentDto ReadContent(byte[] bundle)
{
using var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read);
using var stream = zip.GetEntry("content.json")!.Open();
return JsonSerializer.Deserialize<BundleContentDto>(stream, BundleJsonOptions.Default)!;
}
private async Task<Guid> PackAndLoadAsync(BundleContentDto content)
{
await using var scope = _provider.CreateAsyncScope();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
var summary = new BundleSummary(
Templates: content.Templates.Count,
TemplateFolders: 0, SharedScripts: 0, ExternalSystems: 0, DbConnections: 0,
NotificationLists: 0, SmtpConfigs: 0, ApiMethods: 0);
var manifest = manifestBuilder.Build("dev", "alice", "1.0.0", null, summary,
Array.Empty<ManifestContentEntry>(), serializer.SerializeContentBytes(content));
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
using var ms = new MemoryStream();
await packed.CopyToAsync(ms);
return (await LoadAsync(_provider, ms.ToArray())).SessionId;
}
// ── tests ───────────────────────────────────────────────────────────────────
/// <summary>
/// Export → wipe → import of a base + derived pair: the slot comes back as a DERIVED
/// template owned by its composition and inheriting from the base, with its override and
/// its own addition intact and the base's members as inherited placeholders — not as a
/// base template named after the slot.
/// </summary>
[Fact]
public async Task Base_and_derived_pair_round_trips_with_the_slot_still_derived()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
// The bundle marks the slot derived and carries only its own rows.
var dto = Assert.Single(ReadContent(bundle).Templates, t => t.Name == "M1");
Assert.NotNull(dto.Derivation);
Assert.Equal("Pump", dto.Derivation!.OwnerTemplateName);
Assert.Equal(new[] { "M1" }, dto.Derivation.SlotPath);
Assert.Equal("Motor", dto.Derivation.BaseTemplateName);
Assert.Empty(dto.Derivation.BaseSlotPath);
Assert.Equal(new[] { "Extra", "Speed" }, dto.Attributes.Select(a => a.Name).OrderBy(n => n));
Assert.Empty(dto.Scripts);
// Base templates carry no derivation (their wire shape is unchanged).
Assert.All(ReadContent(bundle).Templates.Where(t => t.Name != "M1"), t => Assert.Null(t.Derivation));
await WipeTemplatesAsync(_provider);
var session = await LoadAsync(_provider, bundle);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Contains(preview.Items, i => i.Name == "Pump.M1" && i.Kind == ConflictKind.New);
Assert.DoesNotContain(preview.Items, i => i.Name == "M1");
var result = await ApplyDefaultsAsync(_provider, session.SessionId, preview);
Assert.Empty(result.Warnings);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "M1" && !t.IsDerived), "the slot must not become a base template");
var motor = await ctx.Templates.Include(t => t.Scripts).SingleAsync(t => t.Name == "Motor");
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
Assert.True(slot.IsDerived);
Assert.Equal("M1", slot.Name);
Assert.Equal(motor.Id, slot.ParentTemplateId);
Assert.Equal(Assert.Single(pump.Compositions).Id, slot.OwnerCompositionId);
var speed = slot.Attributes.Single(a => a.Name == "Speed");
Assert.False(speed.IsInherited);
Assert.Equal("5", speed.Value);
var extra = slot.Attributes.Single(a => a.Name == "Extra");
Assert.False(extra.IsInherited);
Assert.Equal("x", extra.Value);
var mode = slot.Attributes.Single(a => a.Name == "Mode");
Assert.True(mode.IsInherited);
Assert.Equal("a", mode.Value);
Assert.True(slot.Scripts.Single(s => s.Name == "Run").IsInherited);
Assert.True(slot.Alarms.Single(a => a.Name == "High").IsInherited);
}
/// <summary>
/// Derived-ness is part of the identity for the differential import: re-importing the
/// same bundle reports the slot Identical (so it is skipped), and a target base template
/// that merely shares the slot's name is neither matched nor touched.
/// </summary>
[Fact]
public async Task Derived_identity_is_the_owner_path_not_the_slot_name()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
// Same environment: everything is Identical, the slot under its qualified name.
var again = await LoadAsync(_provider, bundle);
var identical = await PreviewAsync(_provider, again.SessionId);
Assert.All(identical.Items, i => Assert.Equal(ConflictKind.Identical, i.Kind));
Assert.Contains(identical.Items, i => i.Name == "Pump.M1");
// A changed override reads Modified on the derived row only.
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
var speed = slot.Attributes.Single(a => a.Name == "Speed");
Ok(await svc.UpdateAttributeAsync(speed.Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "7", IsInherited = false }, "seed"));
return 0;
});
var modified = await PreviewAsync(_provider, (await LoadAsync(_provider, bundle)).SessionId);
Assert.Equal(ConflictKind.Modified, modified.Items.Single(i => i.Name == "Pump.M1").Kind);
Assert.Equal(ConflictKind.Identical, modified.Items.Single(i => i.Name == "Pump").Kind);
// Fresh target holding an unrelated BASE template named like the slot.
await WipeTemplatesAsync(_provider);
await WithServiceAsync(_provider, async (svc, _) =>
{
var m1 = Ok(await svc.CreateTemplateAsync("M1", "unrelated", null, "seed"));
Ok(await svc.AddAttributeAsync(m1.Id, new TemplateAttribute("Own") { DataType = DataType.String, Value = "keep" }, "seed"));
return 0;
});
var session = await LoadAsync(_provider, bundle);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Equal(ConflictKind.New, preview.Items.Single(i => i.Name == "Pump.M1").Kind);
await ApplyDefaultsAsync(_provider, session.SessionId, preview);
await using var scope = _provider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var unrelated = await db.Templates.Include(t => t.Attributes).SingleAsync(t => t.Name == "M1" && !t.IsDerived);
Assert.Equal("unrelated", unrelated.Description);
Assert.Equal(new[] { "Own" }, unrelated.Attributes.Select(a => a.Name));
Assert.True((await LoadSlotAsync(db, "Pump", "M1")).IsDerived);
}
/// <summary>
/// A nested slot (Station composes Pump as P, the cascade creates Station.P.M1 deriving
/// from Pump.M1) round-trips: its base is the source-side DERIVED template, addressed by
/// path, and its own override survives.
/// </summary>
[Fact]
public async Task Nested_cascade_slot_round_trips_onto_the_source_side_derived_base()
{
await SeedPumpWithDerivedMotorAsync(_provider);
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived);
var station = Ok(await svc.CreateTemplateAsync("Station", null, null, "seed"));
Ok(await svc.AddCompositionAsync(station.Id, pump.Id, "P", "seed"));
var p = await LoadSlotAsync(ctx, "Station", "P");
var nestedComp = Assert.Single(p.Compositions);
var nestedSpeed = await ctx.TemplateAttributes.AsNoTracking()
.SingleAsync(a => a.TemplateId == nestedComp.ComposedTemplateId && a.Name == "Speed");
Ok(await svc.UpdateAttributeAsync(nestedSpeed.Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "9", IsInherited = false }, "seed"));
return 0;
});
var bundle = await ExportAsync(_provider, null, "Station");
var nested = Assert.Single(ReadContent(bundle).Templates, t => t.Derivation?.QualifiedName() == "Station.P.M1");
Assert.Equal("Pump.M1", nested.Derivation!.BaseQualifiedName());
await WipeTemplatesAsync(_provider);
Assert.Empty((await RoundTripAsync(_provider, bundle)).Warnings);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pumpM1 = await LoadSlotAsync(ctx, "Pump", "M1");
var p = await LoadSlotAsync(ctx, "Station", "P");
var stationPm1 = await ctx.Templates.Include(t => t.Attributes)
.SingleAsync(t => t.Id == Assert.Single(p.Compositions).ComposedTemplateId);
Assert.True(stationPm1.IsDerived);
Assert.Equal(pumpM1.Id, stationPm1.ParentTemplateId);
Assert.Equal("9", stationPm1.Attributes.Single(a => a.Name == "Speed").Value);
Assert.False(stationPm1.Attributes.Single(a => a.Name == "Speed").IsInherited);
Assert.Equal(3, await ctx.Templates.CountAsync(t => !t.IsDerived)); // Motor, Pump, Station
}
/// <summary>
/// The importer orders derived templates itself (owner and base first): a hand-edited
/// bundle listing every derived template — nested slot first — ahead of the base
/// templates still imports correctly.
/// </summary>
[Fact]
public async Task Derived_templates_listed_before_their_base_and_owner_still_import()
{
await SeedPumpWithDerivedMotorAsync(_provider);
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var pump = await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived);
var station = Ok(await svc.CreateTemplateAsync("Station", null, null, "seed"));
Ok(await svc.AddCompositionAsync(station.Id, pump.Id, "P", "seed"));
return 0;
});
var content = ReadContent(await ExportAsync(_provider, null, "Station"));
var reordered = content.Templates
.OrderByDescending(t => t.Derivation?.SlotPath.Count ?? 0) // deepest derived first, bases last
.ToList();
Assert.Equal("Station.P.M1", reordered[0].Derivation?.QualifiedName());
await WipeTemplatesAsync(_provider);
var sessionId = await PackAndLoadAsync(content with { Templates = reordered });
var preview = await PreviewAsync(_provider, sessionId);
var result = await ApplyDefaultsAsync(_provider, sessionId, preview);
Assert.Empty(result.Warnings);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var p = await LoadSlotAsync(ctx, "Station", "P");
Assert.True(p.IsDerived);
var nested = await ctx.Templates.SingleAsync(t => t.Id == Assert.Single(p.Compositions).ComposedTemplateId);
Assert.Equal((await LoadSlotAsync(ctx, "Pump", "M1")).Id, nested.ParentTemplateId);
Assert.Equal(0, await ctx.Templates.CountAsync(t => (t.Name == "M1" || t.Name == "P") && !t.IsDerived));
}
/// <summary>
/// A derived template whose base is in neither the bundle (Skip) nor the target fails
/// that one record with a clear audit row and result warning; no base template is created
/// in its place, and the rest of the import lands.
/// </summary>
[Fact]
public async Task Missing_base_fails_the_derived_record_with_an_audit_row()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
await WipeTemplatesAsync(_provider);
var result = await RoundTripAsync(_provider, bundle, new ImportResolution("Template", "Motor", ResolutionAction.Skip, null));
var warning = Assert.Single(result.Warnings);
Assert.Contains("Pump.M1", warning, StringComparison.Ordinal);
Assert.Contains("base 'Motor'", warning, StringComparison.Ordinal);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "Motor" || t.Name == "M1"));
Assert.Empty((await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump")).Compositions);
var audit = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleImportDerivedTemplateUnresolved");
Assert.Equal("Pump.M1", audit.EntityName);
Assert.Equal(result.BundleImportId, audit.BundleImportId);
Assert.Contains("no base template was created", audit.AfterStateJson!, StringComparison.Ordinal);
}
/// <summary>
/// A base that is NOT in the bundle but already exists on the target is composed against.
/// </summary>
[Fact]
public async Task Base_already_in_the_target_is_composed_against()
{
await SeedPumpWithDerivedMotorAsync(_provider);
var content = ReadContent(await ExportAsync(_provider, null, "Pump"));
var withoutMotor = content with { Templates = content.Templates.Where(t => t.Name != "Motor").ToList() };
// Target: only Motor survives.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
ctx.Templates.RemoveRange(ctx.Templates.Where(t => t.Name != "Motor"));
await ctx.SaveChangesAsync();
}
var sessionId = await PackAndLoadAsync(withoutMotor);
var result = await ApplyDefaultsAsync(_provider, sessionId, await PreviewAsync(_provider, sessionId));
Assert.Empty(result.Warnings);
await using var assertScope = _provider.CreateAsyncScope();
var db = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var slot = await LoadSlotAsync(db, "Pump", "M1");
Assert.Equal((await db.Templates.SingleAsync(t => t.Name == "Motor")).Id, slot.ParentTemplateId);
Assert.Equal("5", slot.Attributes.Single(a => a.Name == "Speed").Value);
}
/// <summary>
/// Overwrite syncs an existing slot IN PLACE (same derived row, so nested slots that
/// derive from it keep their FK): a changed override is applied and an own row the
/// bundle no longer carries is reverted / removed by the inherited-member resync.
/// </summary>
[Fact]
public async Task Overwrite_syncs_the_existing_slot_in_place()
{
var derivedId = await SeedPumpWithDerivedMotorAsync(_provider);
var bundle = await ExportAsync(_provider, null, "Pump");
await WithServiceAsync(_provider, async (svc, ctx) =>
{
var slot = await LoadSlotAsync(ctx, "Pump", "M1");
Ok(await svc.UpdateAttributeAsync(slot.Attributes.Single(a => a.Name == "Speed").Id,
new TemplateAttribute("Speed") { DataType = DataType.Int32, Value = "7", IsInherited = false }, "seed"));
Ok(await svc.UpdateAttributeAsync(slot.Attributes.Single(a => a.Name == "Mode").Id,
new TemplateAttribute("Mode") { DataType = DataType.String, Value = "z", IsInherited = false }, "seed"));
// (AddAttributeAsync on a slot with overrides trips the collision detector, so the
// stale own row is staged directly — the shape an older edit could leave behind.)
ctx.TemplateAttributes.Add(new TemplateAttribute("Stale") { TemplateId = slot.Id, DataType = DataType.String, Value = "s" });
// The owner is Modified too, so its composition pass runs (Overwrite) — and must
// leave the derived slot's composition row to the derived pass.
(await ctx.Templates.SingleAsync(t => t.Name == "Pump" && !t.IsDerived)).Description = "changed";
await ctx.SaveChangesAsync();
return 0;
});
var session = await LoadAsync(_provider, bundle);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Equal(ConflictKind.Modified, preview.Items.Single(i => i.Name == "Pump").Kind);
Assert.Equal(ConflictKind.Modified, preview.Items.Single(i => i.Name == "Pump.M1").Kind);
await ApplyDefaultsAsync(_provider, session.SessionId, preview);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var after = await LoadSlotAsync(ctx, "Pump", "M1");
Assert.Equal(derivedId, after.Id);
Assert.Equal("5", after.Attributes.Single(a => a.Name == "Speed").Value);
var mode = after.Attributes.Single(a => a.Name == "Mode");
Assert.True(mode.IsInherited);
Assert.Equal("a", mode.Value);
Assert.DoesNotContain(after.Attributes, a => a.Name == "Stale");
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.IsDerived));
Assert.Equal("pump", (await ctx.Templates.SingleAsync(t => t.Name == "Pump")).Description);
}
/// <summary>
/// A derivation cycle (two slots each deriving from the other) is a preview Blocker and
/// an apply-time <see cref="SemanticValidationException"/> naming both, raised before
/// anything is written.
/// </summary>
[Fact]
public async Task Derivation_cycle_is_a_clear_import_error()
{
static TemplateDto Slot(string slot, string baseSlot) =>
new(slot, null, null, null, Array.Empty<TemplateAttributeDto>(), Array.Empty<TemplateAlarmDto>(),
Array.Empty<TemplateScriptDto>(), Array.Empty<TemplateCompositionDto>())
{
Derivation = new TemplateDerivationDto("Pump", new[] { slot }, "Pump", new[] { baseSlot }),
};
var pump = new TemplateDto("Pump", null, null, null, Array.Empty<TemplateAttributeDto>(),
Array.Empty<TemplateAlarmDto>(), Array.Empty<TemplateScriptDto>(),
new[] { new TemplateCompositionDto("X", "X"), new TemplateCompositionDto("Y", "Y") });
var content = new BundleContentDto(
Array.Empty<TemplateFolderDto>(), new[] { pump, Slot("X", "Y"), Slot("Y", "X") },
Array.Empty<SharedScriptDto>(), Array.Empty<ExternalSystemDto>(), Array.Empty<DatabaseConnectionDto>(),
Array.Empty<NotificationListDto>(), Array.Empty<SmtpConfigDto>(), Array.Empty<ApiMethodDto>());
var sessionId = await PackAndLoadAsync(content);
var preview = await PreviewAsync(_provider, sessionId);
var blocker = Assert.Single(preview.Items, i => i.Kind == ConflictKind.Blocker);
Assert.Contains("cycle", blocker.BlockerReason, StringComparison.Ordinal);
await using var scope = _provider.CreateAsyncScope();
var ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
scope.ServiceProvider.GetRequiredService<IBundleImporter>().ApplyAsync(sessionId,
new[] { new ImportResolution("Template", "Pump", ResolutionAction.Add, null) }, user: "bob"));
var error = Assert.Single(ex.Errors);
Assert.Contains("cycle", error, StringComparison.Ordinal);
Assert.Contains("Pump.X", error, StringComparison.Ordinal);
Assert.Contains("Pump.Y", error, StringComparison.Ordinal);
await using var check = _provider.CreateAsyncScope();
Assert.False(await check.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Templates.AnyAsync());
}
/// <summary>
/// Format compatibility: a bundle exported by the pre-#118 code (no derivation field —
/// <c>Fixtures/pre-118-derived-plaintext.scadabundle</c>, a Pump whose slot M1 was a
/// derived template of Motor with Speed overridden to 5) imports exactly as it always did:
/// the slot arrives as a base template named <c>M1</c> inheriting from Motor and composed
/// by Pump. Never regenerate the fixture — its value is that old code wrote it.
/// </summary>
[Fact]
public async Task Pre_118_bundle_without_the_field_imports_exactly_as_before()
{
var bundle = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "pre-118-derived-plaintext.scadabundle"));
Assert.All(ReadContent(bundle).Templates, t => Assert.Null(t.Derivation));
var session = await LoadAsync(_provider, bundle);
Assert.Equal("1.1", session.Manifest.SchemaVersion);
var preview = await PreviewAsync(_provider, session.SessionId);
Assert.Equal(new[] { "M1", "Motor", "Pump" }, preview.Items.Select(i => i.Name).OrderBy(n => n, StringComparer.Ordinal));
Assert.All(preview.Items, i => Assert.Equal(ConflictKind.New, i.Kind));
var result = await ApplyDefaultsAsync(_provider, session.SessionId, preview);
Assert.Equal(3, result.Added);
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var motor = await ctx.Templates.SingleAsync(t => t.Name == "Motor");
var m1 = await ctx.Templates.Include(t => t.Attributes).SingleAsync(t => t.Name == "M1");
Assert.False(m1.IsDerived);
Assert.Null(m1.OwnerCompositionId);
Assert.Equal(motor.Id, m1.ParentTemplateId);
Assert.Equal("5", Assert.Single(m1.Attributes).Value);
var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump");
Assert.Equal(m1.Id, Assert.Single(pump.Compositions).ComposedTemplateId);
}
/// <summary>
/// A signed bundle carrying derivations verifies and imports the slot as derived; the
/// derivation sits inside the content the signed manifest's content hash pins, so editing
/// it is refused — with the old hash (content mismatch) and with a recomputed hash (the
/// signature over the manifest no longer verifies).
/// </summary>
[Fact]
public async Task Signed_bundle_with_derivations_verifies_and_pins_the_field()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var settings = new Dictionary<string, string?>
{
["ScadaBridge:Transport:Signing:Enabled"] = "true",
["ScadaBridge:Transport:Signing:PrivateKey"] = key.ExportPkcs8PrivateKeyPem(),
["ScadaBridge:Transport:Signing:TrustedKeys:0:Name"] = "self",
["ScadaBridge:Transport:Signing:TrustedKeys:0:PublicKey"] = key.ExportSubjectPublicKeyInfoPem(),
["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true",
};
await using var provider = BuildProvider(settings);
await SeedPumpWithDerivedMotorAsync(provider);
var bundle = await ExportAsync(provider, null, "Pump");
Assert.Contains("\"Derivation\"", ReadEntry(bundle, "content.json"), StringComparison.Ordinal);
Assert.Contains("\"SchemaVersion\":\"1.2\"", ReadEntry(bundle, "manifest.json"), StringComparison.Ordinal);
await WipeTemplatesAsync(provider);
var session = await LoadAsync(provider, bundle);
Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus);
var preview = await PreviewAsync(provider, session.SessionId);
await ApplyDefaultsAsync(provider, session.SessionId, preview);
await using (var scope = provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.True((await LoadSlotAsync(ctx, "Pump", "M1")).IsDerived);
}
// Tamper with the derivation only.
var tamperedContent = ReadEntry(bundle, "content.json").Replace("\"BaseTemplateName\":\"Motor\",\"BaseSlotPath\"",
"\"BaseTemplateName\":\"Pump\",\"BaseSlotPath\"", StringComparison.Ordinal);
Assert.NotEqual(ReadEntry(bundle, "content.json"), tamperedContent);
var staleHash = ReplaceEntries(bundle, ("content.json", tamperedContent));
await Assert.ThrowsAnyAsync<Exception>(() => LoadAsync(provider, staleHash));
var newHash = "sha256:" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tamperedContent))).ToLowerInvariant();
var manifest = ReadEntry(bundle, "manifest.json");
var oldHash = JsonDocument.Parse(manifest).RootElement.GetProperty("ContentHash").GetString()!;
var rehashed = ReplaceEntries(bundle,
("content.json", tamperedContent), ("manifest.json", manifest.Replace(oldHash, newHash, StringComparison.Ordinal)));
await Assert.ThrowsAsync<BundleSignatureException>(() => LoadAsync(provider, rehashed));
}
private static string ReadEntry(byte[] bundle, string name)
{
using var zip = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read);
using var reader = new StreamReader(zip.GetEntry(name)!.Open());
return reader.ReadToEnd();
}
private static byte[] ReplaceEntries(byte[] bundle, params (string Name, string Text)[] replacements)
{
var output = new MemoryStream();
using (var source = new ZipArchive(new MemoryStream(bundle), ZipArchiveMode.Read))
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in source.Entries)
{
var replacement = replacements.FirstOrDefault(r => r.Name == entry.FullName);
var newEntry = target.CreateEntry(entry.FullName);
using var dest = newEntry.Open();
if (replacement.Name is not null)
{
var bytes = Encoding.UTF8.GetBytes(replacement.Text);
dest.Write(bytes);
}
else
{
using var src = entry.Open();
src.CopyTo(dest);
}
}
}
return output.ToArray();
}
}
@@ -337,6 +337,31 @@ public sealed class DependencyResolverTests
Assert.Equal("Top", result.Templates[2].Name);
}
/// <summary>
/// ScadaBridge#118: a derived composition-slot template pulls in its BASE (the importer
/// rebuilds the slot by composing that base), and the base is ordered before the slot.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Resolve_pulls_in_the_base_of_a_derived_slot_and_orders_it_first()
{
var motor = new Template("Motor") { Id = 40 };
var slot = new Template("M1") { Id = 41, IsDerived = true, ParentTemplateId = 40, OwnerCompositionId = 7 };
var pump = new Template("Pump") { Id = 42 };
pump.Compositions.Add(new TemplateComposition("M1") { Id = 7, TemplateId = 42, ComposedTemplateId = 41 });
StubTemplate(pump);
StubTemplate(slot);
StubTemplate(motor);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(42), CancellationToken.None);
Assert.Equal(new[] { "Motor", "M1", "Pump" }, result.Templates.Select(t => t.Name));
}
// ---------------------------------------------------------------------------
// Site/instance-scoped expansion.
// ---------------------------------------------------------------------------
@@ -0,0 +1,74 @@
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
/// <summary>
/// ScadaBridge#118 — <see cref="DerivedTemplatePlan"/> orders a bundle's derived templates
/// after their owner and base whatever the bundle order, and reports cycles and malformed
/// derivations as errors.
/// </summary>
public sealed class DerivedTemplatePlanTests
{
private static TemplateDto Base(string name) =>
new(name, null, null, null, Array.Empty<TemplateAttributeDto>(), Array.Empty<TemplateAlarmDto>(),
Array.Empty<TemplateScriptDto>(), Array.Empty<TemplateCompositionDto>());
private static TemplateDto Derived(string owner, string[] slots, string baseName, params string[] baseSlots) =>
Base(slots[^1]) with { Derivation = new TemplateDerivationDto(owner, slots, baseName, baseSlots) };
/// <summary>Nested slots and cascade slots come after their owner and their (derived) base.</summary>
[Fact]
public void Orders_owner_and_base_before_the_derived_template()
{
var plan = DerivedTemplatePlan.Build(new[]
{
Derived("Station", new[] { "P", "M1" }, "Pump", "M1"), // owner Station.P, base Pump.M1
Base("Station"),
Derived("Station", new[] { "P" }, "Pump"),
Derived("Pump", new[] { "M1" }, "Motor"),
});
Assert.Empty(plan.Errors);
var order = plan.Ordered.Select(DerivedTemplatePlan.Key).ToList();
Assert.Equal(3, order.Count);
Assert.True(order.IndexOf("Station.P") < order.IndexOf("Station.P.M1"));
Assert.True(order.IndexOf("Pump.M1") < order.IndexOf("Station.P.M1"));
}
/// <summary>A two-node and a one-node cycle are both reported, naming the templates.</summary>
[Fact]
public void Cycles_are_errors()
{
var two = DerivedTemplatePlan.Build(new[]
{
Derived("Pump", new[] { "X" }, "Pump", "Y"),
Derived("Pump", new[] { "Y" }, "Pump", "X"),
});
var error = Assert.Single(two.Errors);
Assert.Contains("cycle", error, StringComparison.Ordinal);
Assert.Contains("Pump.X", error, StringComparison.Ordinal);
Assert.Contains("Pump.Y", error, StringComparison.Ordinal);
var self = DerivedTemplatePlan.Build(new[] { Derived("Pump", new[] { "X" }, "Pump", "X") });
Assert.Contains("cycle", Assert.Single(self.Errors), StringComparison.Ordinal);
}
/// <summary>Malformed derivations and duplicate identities are errors; base templates are ignored.</summary>
[Fact]
public void Malformed_and_duplicate_derivations_are_errors()
{
var plan = DerivedTemplatePlan.Build(new[]
{
Base("Pump"),
Base("M1") with { Derivation = new TemplateDerivationDto("Pump", Array.Empty<string>(), "Motor", Array.Empty<string>()) },
Derived("Pump", new[] { "M2" }, "Motor"),
Derived("Pump", new[] { "M2" }, "Motor"),
});
Assert.Equal(2, plan.Errors.Count);
Assert.Contains(plan.Errors, e => e.Contains("malformed", StringComparison.Ordinal));
Assert.Contains(plan.Errors, e => e.Contains("more than once", StringComparison.Ordinal));
Assert.Equal("Pump", DerivedTemplatePlan.Key(Base("Pump")));
}
}
@@ -37,7 +37,7 @@ public sealed class ManifestBuilderTests
Assert.Equal("1.0.0", manifest.ScadaBridgeVersion);
Assert.Equal(1, manifest.BundleFormatVersion);
// Schema minor bumped 1.0 → 1.1; format version stays 1.
Assert.Equal("1.1", manifest.SchemaVersion);
Assert.Equal("1.2", manifest.SchemaVersion);
Assert.Null(manifest.Encryption);
}