50a77a8170
TimeoutSeconds is the per-system HTTP timeout ExternalSystemClient reads at call time and is deployed to sites (proto field 6), but Transport bundles silently dropped it: the DTO never carried it, export never wrote it, the importer's Add path reset it to 0, the Overwrite path left the target's stale value untouched, and ArtifactDiff never compared it (so a bundle whose only change was a tuned timeout previewed as Identical). Adds TimeoutSeconds as an additive-optional trailing field on ExternalSystemDto (schemaVersion stays 1.1, bundleFormatVersion unchanged) and threads it through EntitySerializer (export + import), BundleImporter (Add and Overwrite), and ArtifactDiff.CompareExternalSystem. Old-bundle semantics pinned: an absent field (null) falls back to the entity default on Add and leaves the target's existing value untouched on Overwrite/diff. Beefed up the RoundTripEquivalenceTests external-system fixture with a non-default TimeoutSeconds so the reflection round-trip guard actually exercises the field instead of comparing default against default. Swept the rest of the ExternalSystemDto shape against ExternalSystemDefinition field-by-field; no other operative field is missing.
1820 lines
89 KiB
C#
1820 lines
89 KiB
C#
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.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
|
|
|
/// <summary>
|
|
/// Integration tests for <see cref="BundleImporter.ApplyAsync"/>. Reuses the
|
|
/// in-memory host pattern from <see cref="BundleImporterPreviewTests"/> and
|
|
/// <c>BundleExporterTests</c>: real repositories, real EF in-memory provider,
|
|
/// real Transport pipeline.
|
|
/// <para>
|
|
/// In-memory EF caveat: <see cref="DbContext.Database.BeginTransactionAsync"/>
|
|
/// is a no-op on this provider, so the rollback test depends on ApplyAsync's
|
|
/// implementation deferring <c>SaveChangesAsync</c> to a single call just
|
|
/// before <c>CommitAsync</c>. The implementation enforces that contract +
|
|
/// calls <c>ChangeTracker.Clear()</c> on the catch path to defend against
|
|
/// in-memory bleed-through; the rollback test asserts via row counts that the
|
|
/// invariant holds.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class BundleImporterApplyTests : IDisposable
|
|
{
|
|
private readonly ServiceProvider _provider;
|
|
private readonly RecordingScriptArtifactChangeBus _artifactBus = new();
|
|
|
|
public BundleImporterApplyTests()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(
|
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
|
|
|
var dbName = $"BundleImporterApplyTests_{Guid.NewGuid()}";
|
|
// In-memory provider throws by default when BeginTransactionAsync is
|
|
// called (InMemoryEventId.TransactionIgnoredWarning is escalated to an
|
|
// exception). ApplyAsync legitimately opens a transaction for
|
|
// relational providers; downgrade the warning here so the in-memory
|
|
// run is a no-op and the rest of the apply runs through. See the
|
|
// ApplyAsync XML comment for the rollback-safety contract that makes
|
|
// this safe (single deferred SaveChangesAsync + ChangeTracker.Clear
|
|
// on catch).
|
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
|
|
.UseInMemoryDatabase(dbName)
|
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
|
|
|
// S10b: secret-bearing columns (SmsConfiguration.AuthToken, SmtpConfiguration
|
|
// .Credentials, …) require the context's encrypting two-arg ctor with a Data
|
|
// Protection key ring. Register an ephemeral provider and override the
|
|
// AddDbContext registration to construct the encrypting context — mirrors the
|
|
// production AddConfigurationDatabase wiring (and BundleImporterRollbackFailureTests).
|
|
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>();
|
|
// M8: DependencyResolver now injects ISiteRepository to walk the
|
|
// site/data-connection/instance closure; register it or activation fails.
|
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
|
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
|
// M9-T32: FlatteningPipeline gained an ISharedSchemaRepository constructor
|
|
// dependency for schema-library $ref resolution; register the real repo so
|
|
// AddDeploymentManager()'s IFlatteningPipeline → FlatteningPipeline activates.
|
|
// SharedSchemaRepository only needs ScadaBridgeDbContext, which is already
|
|
// registered above as the in-memory provider.
|
|
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
|
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
|
services.AddScoped<IAuditService, AuditService>();
|
|
services.AddTransport();
|
|
// #05-T14: a recording bus so the post-commit ScriptArtifactsChanged
|
|
// publish is observable. BundleImporter takes IScriptArtifactChangeBus?
|
|
// optionally, so this registration flows in via DI activation.
|
|
services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus);
|
|
// #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
|
|
// and the flatten/hash primitives in TemplateEngine — register both so
|
|
// BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
|
|
// compute StaleInstanceIds against genuine revision hashes.
|
|
services.AddTemplateEngine();
|
|
services.AddDeploymentManager();
|
|
|
|
_provider = services.BuildServiceProvider();
|
|
}
|
|
|
|
public void Dispose() => _provider.Dispose();
|
|
|
|
// ---- helpers ----
|
|
|
|
/// <summary>
|
|
/// Exports the entire seeded content as a bundle, then immediately loads it
|
|
/// via <see cref="IBundleImporter.LoadAsync"/> and returns the opened
|
|
/// session. Used by every test that needs a session to feed
|
|
/// <see cref="IBundleImporter.ApplyAsync"/>. Selection is "all templates +
|
|
/// all shared scripts" because the tests want the bundle to carry whatever
|
|
/// the test seeded.
|
|
/// </summary>
|
|
private async Task<Guid> ExportAndLoadAsync()
|
|
{
|
|
Stream bundleStream;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
|
|
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
|
|
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(e => e.Id).ToListAsync();
|
|
var apiMethodIds = await ctx.ApiMethods.Select(m => m.Id).ToListAsync();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: templateIds,
|
|
SharedScriptIds: sharedScriptIds,
|
|
ExternalSystemIds: externalSystemIds,
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: apiMethodIds,
|
|
IncludeDependencies: false);
|
|
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
}
|
|
|
|
using var ms = new MemoryStream();
|
|
await bundleStream.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
|
|
await using var loadScope = _provider.CreateAsyncScope();
|
|
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
return session.SessionId;
|
|
}
|
|
|
|
private async Task WipeContentAsync()
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.RemoveRange(ctx.Templates);
|
|
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
|
|
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
|
|
ctx.ApiMethods.RemoveRange(ctx.ApiMethods);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// ---- tests ----
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_adds_new_artifacts_in_single_transaction()
|
|
{
|
|
// Arrange: seed → export → wipe → apply. The wipe ensures the import
|
|
// is exercising the Add path (the bundle's artifacts are absent from
|
|
// the target).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
|
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
|
|
// Act
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var resolutions = new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Add, null),
|
|
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
|
|
};
|
|
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
|
|
}
|
|
|
|
// Assert
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Pump"));
|
|
Assert.Equal(1, await ctx.SharedScripts.CountAsync(s => s.Name == "HelperFn"));
|
|
}
|
|
Assert.Equal(2, result.Added);
|
|
Assert.Equal(0, result.Overwritten);
|
|
Assert.Equal(0, result.Skipped);
|
|
Assert.NotEqual(Guid.Empty, result.BundleImportId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite()
|
|
{
|
|
// Arrange: seed Pump with Description=new, export, then mutate to
|
|
// Description=old. The bundle still carries "new". Overwrite must
|
|
// restore the description.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "new" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
|
t.Description = "old";
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Act
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
|
Assert.Equal("new", t.Description);
|
|
}
|
|
Assert.Equal(1, result.Overwritten);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_skips_artifact_when_resolution_is_Skip()
|
|
{
|
|
// Arrange: identical seed + bundle; Skip resolution should leave
|
|
// target unchanged and bump Skipped count.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "stable" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
// Act
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Skip, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
// Exactly one Pump still, with Description unchanged.
|
|
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
|
Assert.Equal("stable", t.Description);
|
|
}
|
|
Assert.Equal(1, result.Skipped);
|
|
Assert.Equal(0, result.Added);
|
|
Assert.Equal(0, result.Overwritten);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_renames_artifact_when_resolution_is_Rename()
|
|
{
|
|
// Arrange: seed X, export, wipe so the Rename target Y doesn't
|
|
// collide. Apply Rename X→Y.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("X") { Description = "orig" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
|
|
// Act
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "X", ResolutionAction.Rename, "Y") },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Equal(0, await ctx.Templates.CountAsync(t => t.Name == "X"));
|
|
Assert.Equal(1, await ctx.Templates.CountAsync(t => t.Name == "Y"));
|
|
}
|
|
Assert.Equal(1, result.Renamed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails()
|
|
{
|
|
// Arrange: seed an ApiMethod whose script body calls MissingHelper().
|
|
// No SharedScript by that name exists in source or (after wipe) in the
|
|
// target. ApiMethod name-resolution findings are HARD errors (unlike
|
|
// template-script findings, which are advisory as of #05-T19), so this
|
|
// must reject the apply and roll everything back.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
|
|
// Act
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
|
user: "bob"));
|
|
}
|
|
|
|
// Assert — target still wiped (ApiMethod not committed), AND a
|
|
// BundleImportFailed row exists.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Equal(0, await ctx.ApiMethods.CountAsync());
|
|
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
|
|
}
|
|
|
|
// T-007: a failed apply used to keep the BundleSession (and its decrypted
|
|
// secrets) in the in-memory store for the full 30-minute TTL. The session
|
|
// must now be removed immediately so the plaintext is released.
|
|
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
|
Assert.Null(sessionStore.Get(sessionId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_removes_session_on_success_path_too()
|
|
{
|
|
// T-007: companion to the failed-apply test — the success path must also
|
|
// remove the session (it was already doing so before T-007, but the new
|
|
// test asserts the contract explicitly so a future refactor cannot
|
|
// accidentally leave plaintext in the store).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("PumpForT007") { Description = "fresh" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "PumpForT007", ResolutionAction.Add, null) },
|
|
user: "alice");
|
|
}
|
|
|
|
var sessionStore = _provider.GetRequiredService<IBundleSessionStore>();
|
|
Assert.Null(sessionStore.Get(sessionId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row()
|
|
{
|
|
// The correlation guarantee — every per-entity audit row emitted during
|
|
// ApplyAsync must carry the same BundleImportId as the returned result.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
|
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
// Snapshot the audit-row ids before the apply so the assertion only
|
|
// looks at rows the apply itself emitted (the export wrote a
|
|
// BundleExported row too, with no BundleImportId — that's correct, it
|
|
// wasn't part of an import).
|
|
int beforeMaxId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
beforeMaxId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
|
|
}
|
|
|
|
// Act
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Add, null),
|
|
new("SharedScript", "HelperFn", ResolutionAction.Add, null),
|
|
},
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var newRows = await ctx.AuditLogEntries.Where(a => a.Id > beforeMaxId).ToListAsync();
|
|
// We expect at least: TemplateCreated + SharedScriptCreated + BundleImported.
|
|
Assert.True(newRows.Count >= 3,
|
|
$"Expected at least 3 new audit rows, got {newRows.Count}.");
|
|
Assert.All(newRows, row =>
|
|
Assert.Equal(result.BundleImportId, row.BundleImportId));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_writes_BundleImported_summary_row_inside_transaction()
|
|
{
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Templates.Add(new Template("Pump") { Description = "fresh" });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
|
|
// Act
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Add, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert: BundleImported row exists, has the right SourceEnvironment in
|
|
// its AfterStateJson, and carries the BundleImportId from the result.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImported");
|
|
Assert.NotNull(row);
|
|
Assert.Equal("Bundle", row!.EntityType);
|
|
Assert.Equal(result.BundleImportId, row.BundleImportId);
|
|
Assert.NotNull(row.AfterStateJson);
|
|
Assert.Contains("dev", row.AfterStateJson!, StringComparison.Ordinal);
|
|
// Summary block in payload.
|
|
Assert.Contains("Summary", row.AfterStateJson!, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction()
|
|
{
|
|
// Paired with the rollback test — the failure row IS present even
|
|
// though every other write was rolled back, AND it carries
|
|
// BundleImportId == null (the rolled-back id is intentionally
|
|
// disowned from the failure record).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ApiMethods.Add(new ApiMethod("BrokenIngest", "var x = MissingHelper();"));
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
await WipeContentAsync();
|
|
|
|
// Act
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ApiMethod", "BrokenIngest", ResolutionAction.Add, null) },
|
|
user: "bob"));
|
|
}
|
|
|
|
// Assert
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var row = await ctx.AuditLogEntries.SingleOrDefaultAsync(a => a.Action == "BundleImportFailed");
|
|
Assert.NotNull(row);
|
|
Assert.Equal("Bundle", row!.EntityType);
|
|
// Correlation MUST be null on the failure row — the rolled-back
|
|
// BundleImportId is intentionally disowned.
|
|
Assert.Null(row.BundleImportId);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle()
|
|
{
|
|
// T-001 regression. The Overwrite branch used to write only Description
|
|
// / FolderId on the existing template; the bundle's Attributes / Alarms
|
|
// / Scripts collections were silently dropped on the floor. This test
|
|
// seeds a template with one shape, exports it, mutates the target to a
|
|
// divergent shape, then asserts that Overwrite restores every child
|
|
// collection AND emits per-field audit rows.
|
|
//
|
|
// Bundle shape (exported from "Pump"):
|
|
// Attributes: [SetPoint (Float, 50.0), Pressure (Float, 100.0)]
|
|
// Alarms: [HiAlarm (PriorityLevel=1)]
|
|
// Scripts: [Init (Code="return 1;")]
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Pump") { Description = "from-bundle" };
|
|
t.Attributes.Add(new TemplateAttribute("SetPoint") { DataType = DataType.Float, Value = "50.0" });
|
|
t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "100.0" });
|
|
t.Alarms.Add(new TemplateAlarm("HiAlarm") { PriorityLevel = 1, TriggerType = AlarmTriggerType.ValueMatch });
|
|
t.Scripts.Add(new TemplateScript("Init", "return 1;"));
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
// Mutate the target so every child collection diverges from the bundle.
|
|
// Attributes: SetPoint value mutated, Pressure DELETED, NewAttr ADDED
|
|
// Alarms: HiAlarm PriorityLevel mutated, ExtraAlarm ADDED
|
|
// Scripts: Init code mutated, ExtraScript ADDED
|
|
// Description also mutated so the scalar field still flips.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var pump = await ctx.Templates
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.SingleAsync(t => t.Name == "Pump");
|
|
pump.Description = "target-mutated";
|
|
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
|
|
setPoint.Value = "999.0"; // mutated
|
|
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
|
|
pump.Attributes.Remove(pressure);
|
|
ctx.TemplateAttributes.Remove(pressure);
|
|
pump.Attributes.Add(new TemplateAttribute("NewAttr")
|
|
{
|
|
DataType = DataType.String,
|
|
Value = "should-be-deleted-by-overwrite",
|
|
});
|
|
var hiAlarm = pump.Alarms.Single(a => a.Name == "HiAlarm");
|
|
hiAlarm.PriorityLevel = 99; // mutated
|
|
pump.Alarms.Add(new TemplateAlarm("ExtraAlarm")
|
|
{
|
|
PriorityLevel = 5,
|
|
TriggerType = AlarmTriggerType.RangeViolation,
|
|
});
|
|
var initScript = pump.Scripts.Single(s => s.Name == "Init");
|
|
initScript.Code = "return 999;"; // mutated
|
|
pump.Scripts.Add(new TemplateScript("ExtraScript", "return 0;"));
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Capture the audit baseline so we can scope assertions to rows
|
|
// emitted by THIS apply.
|
|
int beforeMaxAuditId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
|
|
}
|
|
|
|
// Act — apply Overwrite.
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert — children mirror the bundle.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var pump = await ctx.Templates
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.SingleAsync(t => t.Name == "Pump");
|
|
|
|
Assert.Equal("from-bundle", pump.Description);
|
|
|
|
// Attributes — exactly { SetPoint, Pressure }, values restored.
|
|
Assert.Equal(2, pump.Attributes.Count);
|
|
var setPoint = pump.Attributes.Single(a => a.Name == "SetPoint");
|
|
Assert.Equal("50.0", setPoint.Value);
|
|
Assert.Equal(DataType.Float, setPoint.DataType);
|
|
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
|
|
Assert.Equal("100.0", pressure.Value);
|
|
Assert.DoesNotContain(pump.Attributes, a => a.Name == "NewAttr");
|
|
|
|
// Alarms — exactly { HiAlarm }, PriorityLevel restored.
|
|
Assert.Single(pump.Alarms);
|
|
var hi = pump.Alarms.Single();
|
|
Assert.Equal("HiAlarm", hi.Name);
|
|
Assert.Equal(1, hi.PriorityLevel);
|
|
Assert.DoesNotContain(pump.Alarms, a => a.Name == "ExtraAlarm");
|
|
|
|
// Scripts — exactly { Init }, code restored.
|
|
Assert.Single(pump.Scripts);
|
|
var init = pump.Scripts.Single();
|
|
Assert.Equal("Init", init.Name);
|
|
Assert.Equal("return 1;", init.Code);
|
|
Assert.DoesNotContain(pump.Scripts, s => s.Name == "ExtraScript");
|
|
|
|
// Per-field audit rows — design doc enumerates Added / Updated /
|
|
// Deleted shapes; all of these should appear, all stamped with
|
|
// the BundleImportId from the result.
|
|
var newRows = await ctx.AuditLogEntries
|
|
.Where(a => a.Id > beforeMaxAuditId)
|
|
.ToListAsync();
|
|
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
|
|
|
|
// Attribute audit events.
|
|
Assert.Contains(newRows, r => r.Action == "TemplateAttributeUpdated" && r.EntityName == "Pump.SetPoint");
|
|
Assert.Contains(newRows, r => r.Action == "TemplateAttributeAdded" && r.EntityName == "Pump.Pressure");
|
|
Assert.Contains(newRows, r => r.Action == "TemplateAttributeDeleted" && r.EntityName == "Pump.NewAttr");
|
|
|
|
// Alarm audit events.
|
|
Assert.Contains(newRows, r => r.Action == "TemplateAlarmUpdated" && r.EntityName == "Pump.HiAlarm");
|
|
Assert.Contains(newRows, r => r.Action == "TemplateAlarmDeleted" && r.EntityName == "Pump.ExtraAlarm");
|
|
|
|
// Script audit events.
|
|
Assert.Contains(newRows, r => r.Action == "TemplateScriptUpdated" && r.EntityName == "Pump.Init");
|
|
Assert.Contains(newRows, r => r.Action == "TemplateScriptDeleted" && r.EntityName == "Pump.ExtraScript");
|
|
}
|
|
Assert.Equal(1, result.Overwritten);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle()
|
|
{
|
|
// T-002 regression. The ExternalSystem Overwrite branch used to write
|
|
// only EndpointUrl / AuthType / AuthConfiguration on the existing
|
|
// definition; the bundle's Methods collection was silently dropped on
|
|
// the floor. This test seeds an external system with one method
|
|
// shape, exports it, mutates the target's methods to diverge, then
|
|
// asserts that Overwrite restores every method AND emits per-field
|
|
// audit rows.
|
|
//
|
|
// Bundle shape (exported from "Erp"):
|
|
// Methods: [
|
|
// GetUser (GET /users/{id}, param=A, return=R),
|
|
// PostJob (POST /jobs, param=B, return=S),
|
|
// ]
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey");
|
|
ctx.ExternalSystemDefinitions.Add(sys);
|
|
await ctx.SaveChangesAsync();
|
|
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("GetUser", "GET", "/users/{id}")
|
|
{
|
|
ExternalSystemDefinitionId = sys.Id,
|
|
ParameterDefinitions = "A",
|
|
ReturnDefinition = "R",
|
|
});
|
|
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("PostJob", "POST", "/jobs")
|
|
{
|
|
ExternalSystemDefinitionId = sys.Id,
|
|
ParameterDefinitions = "B",
|
|
ReturnDefinition = "S",
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
// Mutate the target so the methods diverge from the bundle:
|
|
// GetUser — Path mutated (UPDATE expected on Overwrite)
|
|
// PostJob — DELETED (ADD expected on Overwrite to restore)
|
|
// ExtraOp — ADDED (DELETE expected on Overwrite to remove)
|
|
// EndpointUrl / AuthType also mutated so the scalar update still fires.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
|
sys.EndpointUrl = "https://wrong.example";
|
|
sys.AuthType = "Basic";
|
|
var getUser = await ctx.ExternalSystemMethods
|
|
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "GetUser");
|
|
getUser.Path = "/wrong/path";
|
|
var postJob = await ctx.ExternalSystemMethods
|
|
.SingleAsync(m => m.ExternalSystemDefinitionId == sys.Id && m.Name == "PostJob");
|
|
ctx.ExternalSystemMethods.Remove(postJob);
|
|
ctx.ExternalSystemMethods.Add(new ExternalSystemMethod("ExtraOp", "DELETE", "/extra")
|
|
{
|
|
ExternalSystemDefinitionId = sys.Id,
|
|
ParameterDefinitions = "X",
|
|
ReturnDefinition = "Y",
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// Capture the audit baseline so we can scope assertions to rows
|
|
// emitted by THIS apply.
|
|
int beforeMaxAuditId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
beforeMaxAuditId = await ctx.AuditLogEntries.MaxAsync(a => (int?)a.Id) ?? 0;
|
|
}
|
|
|
|
// Act — apply Overwrite on the external system.
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert — methods mirror the bundle exactly.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
|
Assert.Equal("https://erp.example", sys.EndpointUrl);
|
|
|
|
var methods = await ctx.ExternalSystemMethods
|
|
.Where(m => m.ExternalSystemDefinitionId == sys.Id)
|
|
.ToListAsync();
|
|
Assert.Equal(2, methods.Count);
|
|
|
|
var getUser = methods.Single(m => m.Name == "GetUser");
|
|
Assert.Equal("GET", getUser.HttpMethod);
|
|
Assert.Equal("/users/{id}", getUser.Path);
|
|
Assert.Equal("A", getUser.ParameterDefinitions);
|
|
Assert.Equal("R", getUser.ReturnDefinition);
|
|
|
|
var postJob = methods.Single(m => m.Name == "PostJob");
|
|
Assert.Equal("POST", postJob.HttpMethod);
|
|
Assert.Equal("/jobs", postJob.Path);
|
|
Assert.Equal("B", postJob.ParameterDefinitions);
|
|
Assert.Equal("S", postJob.ReturnDefinition);
|
|
|
|
Assert.DoesNotContain(methods, m => m.Name == "ExtraOp");
|
|
|
|
// Per-field audit rows — design doc enumerates Added / Updated /
|
|
// Deleted shapes; all of these should appear, all stamped with
|
|
// the BundleImportId from the result.
|
|
var newRows = await ctx.AuditLogEntries
|
|
.Where(a => a.Id > beforeMaxAuditId)
|
|
.ToListAsync();
|
|
Assert.All(newRows, r => Assert.Equal(result.BundleImportId, r.BundleImportId));
|
|
|
|
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodUpdated" && r.EntityName == "Erp.GetUser");
|
|
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodAdded" && r.EntityName == "Erp.PostJob");
|
|
Assert.Contains(newRows, r => r.Action == "ExternalSystemMethodDeleted" && r.EntityName == "Erp.ExtraOp");
|
|
}
|
|
Assert.Equal(1, result.Overwritten);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_adds_new_external_system_with_TimeoutSeconds()
|
|
{
|
|
// arch-review 2026-08-19 P7-1 (row-27 shape): TimeoutSeconds is the
|
|
// per-system HTTP timeout ExternalSystemClient reads at call time — it must
|
|
// survive Add, not just the retry policy (T-002 above covers the sibling
|
|
// Methods-drop regression on Overwrite; this is the Add-path fidelity gap).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey")
|
|
{
|
|
TimeoutSeconds = 120,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
// Wipe so the import exercises the Add path.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Add, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
|
Assert.Equal(120, sys.TimeoutSeconds);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_Overwrite_restores_TimeoutSeconds_to_bundle_value()
|
|
{
|
|
// Seed with TimeoutSeconds=120, export, drift the target to 45, then Overwrite
|
|
// and assert the bundle's value wins (mirrors the retry-policy Overwrite
|
|
// coverage — TimeoutSeconds was the one scalar field the Overwrite branch never
|
|
// touched at all, so it silently kept whatever the target already had).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey")
|
|
{
|
|
TimeoutSeconds = 120,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
|
sys.TimeoutSeconds = 45; // now the only field that differs from the bundle
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
|
Assert.Equal(120, sys.TimeoutSeconds);
|
|
}
|
|
Assert.Equal(1, result.Overwritten);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// arch-review 2026-08-19 P7-1: old-bundle semantics for TimeoutSeconds. Hand-packs
|
|
// a BundleContentDto with an ExternalSystemDto whose TimeoutSeconds is left at its
|
|
// default (null) — the same in-memory shape a pre-P7-1 bundle deserializes to,
|
|
// since the field is simply absent from that JSON text.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
private async Task<Guid> PackAndLoadExternalSystemBundleAsync(params ExternalSystemDto[] externalSystems)
|
|
{
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: externalSystems,
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>());
|
|
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
|
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
|
|
var contentBytes = serializer.SerializeContentBytes(content);
|
|
var manifest = manifestBuilder.Build(
|
|
sourceEnvironment: "dev",
|
|
exportedBy: "alice",
|
|
scadaBridgeVersion: "1.0.0",
|
|
encryption: null,
|
|
summary: new BundleSummary(0, 0, 0, externalSystems.Length, 0, 0, 0, 0),
|
|
contents: Array.Empty<ManifestContentEntry>(),
|
|
contentBytes: contentBytes);
|
|
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
|
|
|
|
using var ms = new MemoryStream();
|
|
await packed.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
return session.SessionId;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_Add_with_old_bundle_missing_TimeoutSeconds_uses_entity_default()
|
|
{
|
|
var dto = new ExternalSystemDto("Erp", "https://erp.example", "ApiKey",
|
|
MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Methods: [], Secrets: null,
|
|
TimeoutSeconds: null); // old-bundle shape — the field never existed on the wire.
|
|
var sessionId = await PackAndLoadExternalSystemBundleAsync(dto);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Add, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
|
Assert.Equal(0, sys.TimeoutSeconds); // entity default, not an exception or -1.
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_Overwrite_with_old_bundle_missing_TimeoutSeconds_preserves_target_value()
|
|
{
|
|
// The target already has a tuned timeout; an old bundle that predates this field
|
|
// must not reset it to 0 on Overwrite — there is nothing on the wire to apply, so
|
|
// the existing value stands.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ExternalSystemDefinitions.Add(new ExternalSystemDefinition("Erp", "https://erp.example", "ApiKey")
|
|
{
|
|
TimeoutSeconds = 90,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var dto = new ExternalSystemDto("Erp", "https://erp.example", "ApiKey",
|
|
MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Methods: [], Secrets: null,
|
|
TimeoutSeconds: null); // old-bundle shape.
|
|
var sessionId = await PackAndLoadExternalSystemBundleAsync(dto);
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ExternalSystem", "Erp", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var sys = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "Erp");
|
|
Assert.Equal(90, sys.TimeoutSeconds); // untouched — bundle carried no opinion.
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// S10b: SMS provider config bundle apply. Mirrors the SMTP apply path —
|
|
// create a new config (Add) and overwrite an existing one (matched by the
|
|
// natural key AccountSid), with the auth token decrypted from the
|
|
// SecretsBlock. Hand-packs a BundleContentDto with SmsConfigs set so the
|
|
// test is decoupled from the export selection (covered separately).
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
private async Task<Guid> PackAndLoadSmsBundleAsync(params SmsConfigDto[] smsConfigs)
|
|
{
|
|
var content = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: Array.Empty<ApiMethodDto>())
|
|
{
|
|
SmsConfigs = smsConfigs,
|
|
};
|
|
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
|
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
|
|
var contentBytes = serializer.SerializeContentBytes(content);
|
|
var manifest = manifestBuilder.Build(
|
|
sourceEnvironment: "dev",
|
|
exportedBy: "alice",
|
|
scadaBridgeVersion: "1.0.0",
|
|
encryption: null,
|
|
summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, SmsConfigs: smsConfigs.Length),
|
|
contents: Array.Empty<ManifestContentEntry>(),
|
|
contentBytes: contentBytes);
|
|
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
|
|
|
|
using var ms = new MemoryStream();
|
|
await packed.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
return session.SessionId;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_creates_new_sms_config_with_decrypted_secret()
|
|
{
|
|
var dto = new SmsConfigDto(
|
|
AccountSid: "AC_new_sid",
|
|
FromNumber: "+15551112222",
|
|
MessagingServiceSid: "MG_svc",
|
|
ApiBaseUrl: "https://api.twilio.example",
|
|
ConnectionTimeoutSeconds: 30,
|
|
MaxRetries: 10,
|
|
RetryDelay: TimeSpan.FromMinutes(1),
|
|
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["AuthToken"] = "fresh-token",
|
|
}));
|
|
var sessionId = await PackAndLoadSmsBundleAsync(dto);
|
|
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("SmsConfiguration", "AC_new_sid", ResolutionAction.Add, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var saved = await ctx.Set<Commons.Entities.Notifications.SmsConfiguration>()
|
|
.SingleAsync(s => s.AccountSid == "AC_new_sid");
|
|
Assert.Equal("+15551112222", saved.FromNumber);
|
|
Assert.Equal("MG_svc", saved.MessagingServiceSid);
|
|
Assert.Equal("fresh-token", saved.AuthToken);
|
|
}
|
|
Assert.Equal(1, result.Added);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_overwrites_existing_sms_config_matched_by_account_sid()
|
|
{
|
|
// Seed an existing config with the SAME AccountSid but stale field + secret.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.Set<Commons.Entities.Notifications.SmsConfiguration>().Add(
|
|
new Commons.Entities.Notifications.SmsConfiguration("AC_existing_sid", "+15550000000")
|
|
{
|
|
AuthToken = "old-token",
|
|
ApiBaseUrl = "https://old.example",
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
var dto = new SmsConfigDto(
|
|
AccountSid: "AC_existing_sid",
|
|
FromNumber: "+15559998888",
|
|
MessagingServiceSid: null,
|
|
ApiBaseUrl: "https://new.example",
|
|
ConnectionTimeoutSeconds: 45,
|
|
MaxRetries: 5,
|
|
RetryDelay: TimeSpan.FromMinutes(2),
|
|
Secrets: new SecretsBlock(new Dictionary<string, string>(StringComparer.Ordinal)
|
|
{
|
|
["AuthToken"] = "new-token",
|
|
}));
|
|
var sessionId = await PackAndLoadSmsBundleAsync(dto);
|
|
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("SmsConfiguration", "AC_existing_sid", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
// Exactly one row for this AccountSid — Overwrite updated in place, no dup.
|
|
var saved = await ctx.Set<Commons.Entities.Notifications.SmsConfiguration>()
|
|
.SingleAsync(s => s.AccountSid == "AC_existing_sid");
|
|
Assert.Equal("+15559998888", saved.FromNumber);
|
|
Assert.Equal("https://new.example", saved.ApiBaseUrl);
|
|
Assert.Equal("new-token", saved.AuthToken);
|
|
}
|
|
Assert.Equal(1, result.Overwritten);
|
|
Assert.Equal(0, result.Added);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Re-arch C4 backward-compat: a LEGACY (pre-C4) bundle still carries an
|
|
// ApiKeys section. The importer must ignore those keys gracefully — it must
|
|
// NOT fail to parse, must NOT create any ApiKey rows, and must surface the
|
|
// ignored-key count on the result (so the operator knows to re-issue keys).
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public async Task ApplyAsync_ignores_legacy_api_keys_in_bundle_without_failing()
|
|
{
|
|
// Arrange: hand-pack a legacy bundle whose content JSON contains an
|
|
// ApiKeys array plus one API method. New exports never emit ApiKeys, so
|
|
// we build the BundleContentDto directly with a populated (non-null)
|
|
// legacy ApiKeys list to faithfully simulate a pre-C4 file.
|
|
var legacyContent = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: new[]
|
|
{
|
|
new ApiMethodDto("CreateOrder", "return 1;",
|
|
ParameterDefinitions: null, ReturnDefinition: null, TimeoutSeconds: 30),
|
|
},
|
|
ApiKeys: new[]
|
|
{
|
|
new ApiKeyDto("legacy-key-a", "hash-a", IsEnabled: true, Secrets: null),
|
|
new ApiKeyDto("legacy-key-b", "hash-b", IsEnabled: false, Secrets: null),
|
|
});
|
|
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
|
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
|
|
var contentBytes = serializer.SerializeContentBytes(legacyContent);
|
|
|
|
// Sanity: the packed content really does carry an ApiKeys section, so
|
|
// the test is exercising the legacy-ignore path rather than a no-op.
|
|
var contentJson = System.Text.Encoding.UTF8.GetString(contentBytes);
|
|
Assert.Contains("legacy-key-a", contentJson);
|
|
|
|
var manifest = manifestBuilder.Build(
|
|
sourceEnvironment: "legacy-env",
|
|
exportedBy: "alice",
|
|
scadaBridgeVersion: "0.9.0",
|
|
encryption: null,
|
|
summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 1),
|
|
contents: Array.Empty<ManifestContentEntry>(),
|
|
contentBytes: contentBytes);
|
|
await using var packed = serializer.Pack(legacyContent, manifest, passphrase: null, encryptor: null);
|
|
|
|
using var ms = new MemoryStream();
|
|
await packed.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
sessionId = session.SessionId;
|
|
}
|
|
|
|
// Act — apply with a resolution only for the method; the keys carry no
|
|
// resolutions (the preview never surfaces them) and must be ignored.
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ApiMethod", "CreateOrder", ResolutionAction.Add, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert — the method WAS created, the ignored count is surfaced, and the
|
|
// import did not fault. Auth re-arch (C5): the SQL Server ApiKey store was
|
|
// retired, so "no keys created" is now structural — the importer has no key
|
|
// sink at all; the legacy ApiKeys section is counted (ApiKeysIgnored) and
|
|
// discarded.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var inboundRepo = scope.ServiceProvider.GetRequiredService<IInboundApiRepository>();
|
|
var methods = await inboundRepo.GetAllApiMethodsAsync();
|
|
Assert.Single(methods);
|
|
Assert.Equal("CreateOrder", methods[0].Name);
|
|
}
|
|
|
|
Assert.Equal(2, result.ApiKeysIgnored);
|
|
Assert.Equal(1, result.Added); // the API method
|
|
Assert.NotEqual(Guid.Empty, result.BundleImportId);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Fix I-3: PreviewAsync must also tolerate a legacy bundle that carries
|
|
// an ApiKeys section without surfacing those keys as importable preview
|
|
// rows. Mirror the ApplyAsync_ignores_legacy_api_keys_in_bundle_without_failing
|
|
// setup: hand-pack a legacy bundle with a populated ApiKeys section plus
|
|
// one ApiMethod, then assert that PreviewAsync completes without fault,
|
|
// surfaces no ApiKey/key preview items, and DOES surface the ApiMethod
|
|
// preview item.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
[Fact]
|
|
public async Task PreviewAsync_on_legacy_bundle_does_not_surface_key_items()
|
|
{
|
|
// Arrange: hand-pack a legacy bundle whose content JSON contains an
|
|
// ApiKeys array plus one API method. New exports never emit ApiKeys,
|
|
// so we build the BundleContentDto directly with a populated (non-null)
|
|
// legacy ApiKeys list to faithfully simulate a pre-C4 file.
|
|
var legacyContent = new BundleContentDto(
|
|
TemplateFolders: Array.Empty<TemplateFolderDto>(),
|
|
Templates: Array.Empty<TemplateDto>(),
|
|
SharedScripts: Array.Empty<SharedScriptDto>(),
|
|
ExternalSystems: Array.Empty<ExternalSystemDto>(),
|
|
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
|
|
NotificationLists: Array.Empty<NotificationListDto>(),
|
|
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
|
|
ApiMethods: new[]
|
|
{
|
|
new ApiMethodDto("GetStatus", "return 0;",
|
|
ParameterDefinitions: null, ReturnDefinition: null, TimeoutSeconds: 30),
|
|
},
|
|
ApiKeys: new[]
|
|
{
|
|
new ApiKeyDto("legacy-key-x", "hash-x", IsEnabled: true, Secrets: null),
|
|
});
|
|
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
|
|
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
|
|
var contentBytes = serializer.SerializeContentBytes(legacyContent);
|
|
|
|
// Sanity: the packed content really does carry an ApiKeys section,
|
|
// so the test is exercising the legacy-ignore path rather than a no-op.
|
|
var contentJson = System.Text.Encoding.UTF8.GetString(contentBytes);
|
|
Assert.Contains("legacy-key-x", contentJson);
|
|
|
|
var manifest = manifestBuilder.Build(
|
|
sourceEnvironment: "legacy-env",
|
|
exportedBy: "alice",
|
|
scadaBridgeVersion: "0.9.0",
|
|
encryption: null,
|
|
summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 1),
|
|
contents: Array.Empty<ManifestContentEntry>(),
|
|
contentBytes: contentBytes);
|
|
await using var packed = serializer.Pack(legacyContent, manifest, passphrase: null, encryptor: null);
|
|
|
|
using var ms = new MemoryStream();
|
|
await packed.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
sessionId = session.SessionId;
|
|
}
|
|
|
|
// Act — call PreviewAsync; must not throw.
|
|
ImportPreview preview;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
preview = await importer.PreviewAsync(sessionId);
|
|
}
|
|
|
|
// Assert — no ApiKey preview rows surfaced (keys are not transported).
|
|
Assert.DoesNotContain(preview.Items, item =>
|
|
item.EntityType.Contains("Key", StringComparison.OrdinalIgnoreCase) ||
|
|
item.Name.Contains("legacy-key", StringComparison.OrdinalIgnoreCase));
|
|
|
|
// The one ApiMethod IS surfaced as a New item (method does not exist in
|
|
// the target DB, so ConflictKind.New is expected).
|
|
Assert.Contains(preview.Items, item =>
|
|
item.EntityType == "ApiMethod" && item.Name == "GetStatus");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// #16 (M8 D2): StaleInstanceIds enumeration. Overwriting a template changes
|
|
// its flattened-config hash, so deployed instances of that template drift
|
|
// from their DeployedConfigSnapshot.RevisionHash and must surface as stale
|
|
// in the import result. NotDeployed (incl. freshly-imported) instances are
|
|
// never stale.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Seeds an Enabled instance of <paramref name="templateName"/> and a
|
|
/// <see cref="DeployedConfigSnapshot"/> whose RevisionHash is the GENUINE hash
|
|
/// the deployment flattening pipeline computes for the instance's CURRENT
|
|
/// (pre-import) config. Returns the instance id. Mirrors how the deploy path
|
|
/// captures a snapshot so the staleness comparison is faithful.
|
|
/// </summary>
|
|
private async Task<int> SeedDeployedInstanceWithRealSnapshotAsync(
|
|
string templateName, string instanceName)
|
|
{
|
|
int instanceId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var template = await ctx.Templates.SingleAsync(t => t.Name == templateName);
|
|
var instance = new Instance(instanceName)
|
|
{
|
|
TemplateId = template.Id,
|
|
SiteId = (await ctx.Sites.FirstAsync()).Id,
|
|
State = InstanceState.Enabled,
|
|
};
|
|
ctx.Instances.Add(instance);
|
|
await ctx.SaveChangesAsync();
|
|
instanceId = instance.Id;
|
|
|
|
// Compute the REAL current revision hash via the same pipeline the
|
|
// deploy path uses, then persist it as the deployed snapshot so the
|
|
// import's drift comparison runs against a genuine baseline.
|
|
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
|
|
var flattened = await pipeline.FlattenAndValidateAsync(instanceId);
|
|
Assert.True(flattened.IsSuccess,
|
|
$"Seed flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}");
|
|
ctx.DeployedConfigSnapshots.Add(new DeployedConfigSnapshot(
|
|
deploymentId: Guid.NewGuid().ToString(),
|
|
revisionHash: flattened.Value.RevisionHash,
|
|
configurationJson: System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration))
|
|
{
|
|
InstanceId = instanceId,
|
|
DeployedAt = DateTimeOffset.UtcNow,
|
|
});
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
return instanceId;
|
|
}
|
|
|
|
private async Task<int> SeedSiteAsync()
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant 1", "plant-1")
|
|
{
|
|
NodeAAddress = "akka://site@10.0.0.1:2552",
|
|
NodeBAddress = "akka://site@10.0.0.2:2552",
|
|
};
|
|
ctx.Sites.Add(site);
|
|
await ctx.SaveChangesAsync();
|
|
return site.Id;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale()
|
|
{
|
|
// Arrange — seed Pump with the BUNDLE's (new) attribute shape, export it,
|
|
// then mutate the target template + reseed the deployed snapshot to the
|
|
// OLD shape so an Overwrite restores the new shape and drifts the deployed
|
|
// instance off its captured hash.
|
|
await SeedSiteAsync();
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Pump") { Description = "v2" };
|
|
t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
|
|
t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" }); // extra attr in bundle
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
// Mutate the target template to the OLD shape (drop Pressure, change Flow),
|
|
// then deploy an instance capturing the OLD config's hash.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
|
|
t.Description = "v1";
|
|
var pressure = t.Attributes.Single(a => a.Name == "Pressure");
|
|
t.Attributes.Remove(pressure);
|
|
ctx.TemplateAttributes.Remove(pressure);
|
|
t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
|
|
|
|
// Act — Overwrite Pump back to the bundle's v2 shape.
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert — the deployed instance drifted off its snapshot hash and is stale.
|
|
Assert.Equal(1, result.Overwritten);
|
|
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale()
|
|
{
|
|
// Two templates: Pump (overwritten with a CHANGED shape) and Valve
|
|
// (overwritten with an IDENTICAL shape — its instance must NOT be stale).
|
|
// The export carries both at their seeded shape; we mutate only Pump in
|
|
// the target so its overwrite changes the hash, while Valve's overwrite
|
|
// is a no-op hash-wise.
|
|
await SeedSiteAsync();
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var pump = new Template("Pump") { Description = "v2" };
|
|
pump.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
|
|
pump.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" });
|
|
ctx.Templates.Add(pump);
|
|
var valve = new Template("Valve") { Description = "stable" };
|
|
valve.Attributes.Add(new TemplateAttribute("Position") { DataType = DataType.Float, Value = "0.5" });
|
|
ctx.Templates.Add(valve);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
// Mutate ONLY Pump in the target so its overwrite changes the hash; leave
|
|
// Valve identical to the bundle so its overwrite is hash-neutral.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var pump = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
|
|
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
|
|
pump.Attributes.Remove(pressure);
|
|
ctx.TemplateAttributes.Remove(pressure);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var pumpInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
|
|
var valveInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Valve", "Valve-Deployed");
|
|
|
|
// Act — Overwrite BOTH templates.
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Overwrite, null),
|
|
new("Template", "Valve", ResolutionAction.Overwrite, null),
|
|
},
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert — only Pump's deployed instance is stale; Valve's is not.
|
|
Assert.Contains(pumpInstanceId, result.StaleInstanceIds);
|
|
Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale()
|
|
{
|
|
// A bundle that carries a template AND an instance of it: the imported
|
|
// instance enters the target NotDeployed (no snapshot), so even though the
|
|
// template is "new" to the target it must NOT appear in StaleInstanceIds.
|
|
// We also overwrite an EXISTING deployed instance's template to prove the
|
|
// stale list contains the deployed one but never the fresh one.
|
|
await SeedSiteAsync();
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Pump") { Description = "v2" };
|
|
t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionId = await ExportAndLoadAsync();
|
|
|
|
// Mutate the target template, then deploy an instance capturing the OLD hash.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
|
|
t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
|
|
|
|
// Also seed a NotDeployed instance of the same template (simulates a
|
|
// never-deployed / freshly-imported instance) — it has no snapshot.
|
|
int notDeployedInstanceId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
|
|
var fresh = new Instance("Pump-Fresh")
|
|
{
|
|
TemplateId = t.Id,
|
|
SiteId = (await ctx.Sites.FirstAsync()).Id,
|
|
State = InstanceState.NotDeployed,
|
|
};
|
|
ctx.Instances.Add(fresh);
|
|
await ctx.SaveChangesAsync();
|
|
notDeployedInstanceId = fresh.Id;
|
|
}
|
|
|
|
// Act — Overwrite Pump.
|
|
ImportResult result;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
result = await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert — deployed instance is stale; the NotDeployed one is excluded.
|
|
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
|
|
Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Two consecutive imports overwriting the SAME template must both report the
|
|
/// deployed instance as stale.
|
|
///
|
|
/// <para>
|
|
/// <c>StaleInstanceProbe</c> memoises its computed revision hash against
|
|
/// <c>ITemplateGraphWatermark</c> readings, in a PROCESS-STATIC dictionary. The
|
|
/// importer commits through the raw <c>ScadaBridgeDbContext</c>, bypassing
|
|
/// <c>TemplateEngineRepository.SaveChangesAsync</c> — the only place that
|
|
/// otherwise derives watermark bumps from the change tracker — so before the
|
|
/// fix nothing an import wrote ever moved the watermark. The second import then
|
|
/// re-served the FIRST import's hash and the instance silently dropped out of
|
|
/// <c>ImportResult.StaleInstanceIds</c>: the operator is told nothing needs
|
|
/// redeploying while the site runs a config that has drifted twice.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The snapshot is refreshed between the two imports (simulating the redeploy
|
|
/// the first stale report prompts), so the second import's staleness verdict
|
|
/// depends entirely on a FRESH hash rather than on the leftover drift.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ApplyAsync_second_overwrite_of_same_template_still_marks_instance_stale()
|
|
{
|
|
// The memo is process-static and keyed by instance id alone; start clean so
|
|
// this test does not inherit another class's entry.
|
|
StaleInstanceProbe.ClearMemos();
|
|
|
|
await SeedSiteAsync();
|
|
|
|
// Shape A → bundle A.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Pump") { Description = "shape-a" };
|
|
t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
|
|
ctx.Templates.Add(t);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionA = await ExportAndLoadAsync();
|
|
|
|
// Shape B → bundle B (a DIFFERENT flattened output from shape A).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
|
|
t.Description = "shape-b";
|
|
t.Attributes.Single(a => a.Name == "Flow").Value = "2.0";
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var sessionB = await ExportAndLoadAsync();
|
|
|
|
// Target starts on a third shape, and the deployed snapshot captures it.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
|
|
t.Description = "shape-target";
|
|
t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
var instanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
|
|
|
|
// Import #1 — Overwrite to shape A. Drifts the instance off its snapshot.
|
|
ImportResult first;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
first = await importer.ApplyAsync(sessionA,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
Assert.Contains(instanceId, first.StaleInstanceIds);
|
|
|
|
// The operator redeploys: the snapshot now matches the post-import-#1 config,
|
|
// so the instance is genuinely up to date going into import #2.
|
|
await RefreshDeployedSnapshotAsync(instanceId);
|
|
|
|
// Import #2 — Overwrite the SAME template to shape B. The instance drifts
|
|
// again and must be reported again.
|
|
ImportResult second;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
second = await importer.ApplyAsync(sessionB,
|
|
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
Assert.Equal(1, second.Overwritten);
|
|
Assert.Contains(instanceId, second.StaleInstanceIds);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-points the instance's <see cref="DeployedConfigSnapshot"/> at its CURRENT
|
|
/// flattened revision hash — the state a successful redeploy would leave behind.
|
|
/// </summary>
|
|
/// <param name="instanceId">Instance whose snapshot is refreshed.</param>
|
|
/// <returns>A task that completes once the snapshot is updated.</returns>
|
|
private async Task RefreshDeployedSnapshotAsync(int instanceId)
|
|
{
|
|
await using var scope = _provider.CreateAsyncScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
|
|
|
|
var flattened = await pipeline.FlattenAndValidateAsync(instanceId);
|
|
Assert.True(flattened.IsSuccess,
|
|
$"Snapshot refresh flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}");
|
|
|
|
var snapshot = await ctx.DeployedConfigSnapshots.SingleAsync(s => s.InstanceId == instanceId);
|
|
snapshot.RevisionHash = flattened.Value.RevisionHash;
|
|
snapshot.ConfigurationJson = System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// ============ #05-T14: post-commit ScriptArtifactsChanged publish ============
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit()
|
|
{
|
|
// Seed a Template, a SharedScript and an ApiMethod; Overwrite all three.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var t = new Template("Pump") { Description = "tpl" };
|
|
t.Scripts.Add(new TemplateScript("init", "return 1;"));
|
|
ctx.Templates.Add(t);
|
|
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
|
ctx.ApiMethods.Add(new ApiMethod("DoThing", "return 1;") { TimeoutSeconds = 30 });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
|
SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(),
|
|
ExternalSystemIds: Array.Empty<int>(),
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
|
|
IncludeDependencies: false);
|
|
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
using var ms = new MemoryStream();
|
|
await stream.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
|
sessionId = session.SessionId;
|
|
}
|
|
|
|
// Act — Overwrite all three (they still exist in the target).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution>
|
|
{
|
|
new("Template", "Pump", ResolutionAction.Overwrite, null),
|
|
new("SharedScript", "HelperFn", ResolutionAction.Overwrite, null),
|
|
new("ApiMethod", "DoThing", ResolutionAction.Overwrite, null),
|
|
},
|
|
user: "bob");
|
|
}
|
|
|
|
// Assert — exactly one notification per kind, post-commit, with the names.
|
|
var byKind = _artifactBus.Received.ToDictionary(n => n.ArtifactKind);
|
|
Assert.Equal(3, _artifactBus.Received.Count);
|
|
Assert.Equal(new[] { "DoThing" }, byKind[ScriptArtifactKinds.ApiMethod].Names);
|
|
Assert.Equal(new[] { "HelperFn" }, byKind[ScriptArtifactKinds.SharedScript].Names);
|
|
Assert.Equal(new[] { "Pump" }, byKind[ScriptArtifactKinds.Template].Names);
|
|
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
|
|
{
|
|
// ApiMethod imported as Add into a target where the name does not exist.
|
|
// Delete-then-reimport-as-Add is the N3 edge: a node can still hold a
|
|
// _knownBadMethods / compiled-handler entry under that very name, so Adds
|
|
// must notify too (over-approximation is safe per the bus contract).
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ApiMethods.Add(new ApiMethod("DelmiaRecipeDownload", "return 1;") { TimeoutSeconds = 30 });
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: Array.Empty<int>(),
|
|
SharedScriptIds: Array.Empty<int>(),
|
|
ExternalSystemIds: Array.Empty<int>(),
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
|
|
IncludeDependencies: false);
|
|
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
using var ms = new MemoryStream();
|
|
await stream.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
|
|
// Delete the target method so the resolution is a genuine Add.
|
|
ctx.ApiMethods.RemoveRange(await ctx.ApiMethods.ToListAsync());
|
|
await ctx.SaveChangesAsync();
|
|
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ApiMethod", "DelmiaRecipeDownload", ResolutionAction.Add, null) },
|
|
user: "bob");
|
|
}
|
|
|
|
var notification = Assert.Single(_artifactBus.Received,
|
|
n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod);
|
|
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
|
{
|
|
// An ApiMethod whose script calls an unresolved helper fails semantic
|
|
// validation BEFORE commit (ApiMethod findings are hard errors), so
|
|
// nothing is published.
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
ctx.ApiMethods.Add(new ApiMethod("Bad", "var x = UnknownHelper();"));
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
Guid sessionId;
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var selection = new ExportSelection(
|
|
TemplateIds: Array.Empty<int>(),
|
|
SharedScriptIds: Array.Empty<int>(),
|
|
ExternalSystemIds: Array.Empty<int>(),
|
|
DatabaseConnectionIds: Array.Empty<int>(),
|
|
NotificationListIds: Array.Empty<int>(),
|
|
SmtpConfigurationIds: Array.Empty<int>(),
|
|
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
|
|
IncludeDependencies: false);
|
|
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
|
passphrase: null, cancellationToken: CancellationToken.None);
|
|
using var ms = new MemoryStream();
|
|
await stream.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
|
|
}
|
|
|
|
await using (var scope = _provider.CreateAsyncScope())
|
|
{
|
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
|
await Assert.ThrowsAsync<SemanticValidationException>(() =>
|
|
importer.ApplyAsync(sessionId,
|
|
new List<ImportResolution> { new("ApiMethod", "Bad", ResolutionAction.Add, null) },
|
|
user: "bob"));
|
|
}
|
|
|
|
Assert.Empty(_artifactBus.Received);
|
|
}
|
|
|
|
/// <summary>Records every published notification for assertion.</summary>
|
|
private sealed class RecordingScriptArtifactChangeBus : IScriptArtifactChangeBus
|
|
{
|
|
public List<ScriptArtifactsChanged> Received { get; } = new();
|
|
|
|
public void Publish(ScriptArtifactsChanged notification) => Received.Add(notification);
|
|
|
|
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler) =>
|
|
throw new NotSupportedException("The recording bus does not support subscriptions.");
|
|
}
|
|
}
|