Files
scadalink-design/tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs

290 lines
14 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Entities.ExternalSystems;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Entities.Scripts;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Services;
using ScadaLink.Transport;
using ScadaLink.Transport.Serialization;
namespace ScadaLink.Transport.IntegrationTests;
/// <summary>
/// T25 — full export→wipe→import round-trip through the real DI pipeline.
/// Confirms the bundle's content survives a complete cycle and that the
/// audit story (BundleExported on the way out, BundleImported on the way
/// in, plus per-entity stamping with BundleImportId) lines up.
/// <para>
/// Uses the same in-memory host pattern as <c>BundleExporterTests</c> and
/// <c>BundleImporterApplyTests</c> — real repositories, in-memory EF
/// provider, real Transport pipeline. The InMemory transaction warning is
/// downgraded for the same reason it is in <c>BundleImporterApplyTests</c>:
/// <c>ApplyAsync</c> opens a transaction that's a no-op here, and the
/// rollback-safety contract (single deferred SaveChangesAsync +
/// ChangeTracker.Clear on catch) keeps the test honest.
/// </para>
/// </summary>
public sealed class RoundTripTests : IDisposable
{
private readonly ServiceProvider _provider;
public RoundTripTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
// Same db across all scopes — see BundleExporterTests for the rationale.
var dbName = $"RoundTripTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaLinkDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
_provider = services.BuildServiceProvider();
}
public void Dispose() => _provider.Dispose();
[Fact]
public async Task Export_then_wipe_then_import_restores_state()
{
// ---- 1. Seed: 3 templates (one composing another + a standalone), 1
// SharedScript referenced by template script body, 1
// ExternalSystem referenced by template script body, 1
// NotificationList. ----
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
ctx.ExternalSystemDefinitions.Add(
new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"));
ctx.NotificationLists.Add(new NotificationList("OnCall"));
await ctx.SaveChangesAsync();
var baseTemplate = new Template("BaseDevice") { Description = "shared base" };
// Script body references HelperFn + ErpSystem so the resolver pulls
// them in via the substring scan. We deliberately keep the body
// free of method-call tokens (e.g. `.Call(`) so the importer's
// identifier-scan doesn't flag well-known SDK methods as missing
// SharedScript blockers.
baseTemplate.Scripts.Add(new TemplateScript("init",
"var x = HelperFn(); var sys = ErpSystem;"));
ctx.Templates.Add(baseTemplate);
await ctx.SaveChangesAsync();
var composing = new Template("Pump") { Description = "composes BaseDevice" };
ctx.Templates.Add(composing);
await ctx.SaveChangesAsync();
composing.Compositions.Add(new TemplateComposition("base")
{
TemplateId = composing.Id,
ComposedTemplateId = baseTemplate.Id,
});
ctx.Templates.Add(new Template("Standalone") { Description = "no deps" });
await ctx.SaveChangesAsync();
}
// ---- 2. Export everything as a single encrypted bundle. ----
byte[] bundleBytes;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync();
var notificationListIds = await ctx.NotificationLists.Select(n => n.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: templateIds,
SharedScriptIds: sharedScriptIds,
ExternalSystemIds: externalSystemIds,
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: notificationListIds,
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
var stream = await exporter.ExportAsync(
selection, user: "alice", sourceEnvironment: "dev",
passphrase: "secret", cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
Assert.NotEmpty(bundleBytes);
// Read manifest from the bundle so we can pin the ContentHash that
// the BundleImported audit row should echo back inside its
// AfterStateJson — that's the round-trip identity check.
string manifestContentHash;
await using (var scope = _provider.CreateAsyncScope())
{
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
using var ms = new MemoryStream(bundleBytes, writable: false);
var manifest = serializer.ReadManifest(ms);
manifestContentHash = manifest.ContentHash;
}
Assert.False(string.IsNullOrEmpty(manifestContentHash));
// ---- 3. Wipe — delete every seeded entity AND the audit rows the
// export wrote so the post-import assertion can scan the
// whole table unambiguously. ----
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
ctx.Templates.RemoveRange(ctx.Templates);
ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients);
ctx.NotificationLists.RemoveRange(ctx.NotificationLists);
ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
await ctx.SaveChangesAsync();
}
// ---- 4. Load the bundle → session. ----
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var ms = new MemoryStream(bundleBytes, writable: false);
var session = await importer.LoadAsync(ms, passphrase: "secret");
sessionId = session.SessionId;
}
// ---- 5. Preview — every item must be ConflictKind.New now that the
// target is empty. ----
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
preview = await importer.PreviewAsync(sessionId);
}
Assert.NotEmpty(preview.Items);
Assert.All(preview.Items, item =>
Assert.Equal(ConflictKind.New, item.Kind));
// ---- 6. Build resolutions: every item gets Add. ----
var resolutions = preview.Items
.Select(it => new ImportResolution(it.EntityType, it.Name, ResolutionAction.Add, null))
.ToList();
// ---- 7. Apply. ----
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
}
// ---- 8. Assertions ----
await using (var assertScope = _provider.CreateAsyncScope())
{
var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
// 8a. Original entities are all back. Re-query by name and spot-check
// the surviving key fields.
var baseDevice = await ctx.Templates
.Include(t => t.Scripts)
.SingleAsync(t => t.Name == "BaseDevice");
Assert.Equal("shared base", baseDevice.Description);
Assert.Single(baseDevice.Scripts);
Assert.Equal("init", baseDevice.Scripts.First().Name);
var pump = await ctx.Templates
.Include(t => t.Compositions)
.SingleAsync(t => t.Name == "Pump");
Assert.Equal("composes BaseDevice", pump.Description);
// FU-B / #39 — composition graph IS restored on import. The bundle
// carried Pump composing BaseDevice via InstanceName="base"; the
// importer's second pass (ResolveCompositionEdgesAsync) re-resolved
// ComposedTemplateName to BaseDevice's new id after the template
// flush and re-added the row.
var pumpComp = Assert.Single(pump.Compositions);
Assert.Equal("base", pumpComp.InstanceName);
Assert.Equal(baseDevice.Id, pumpComp.ComposedTemplateId);
var standalone = await ctx.Templates.SingleAsync(t => t.Name == "Standalone");
Assert.Equal("no deps", standalone.Description);
var helper = await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn");
Assert.Equal("return 1;", helper.Code);
var erp = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "ErpSystem");
Assert.Equal("https://erp.example", erp.EndpointUrl);
var onCall = await ctx.NotificationLists.SingleAsync(n => n.Name == "OnCall");
Assert.NotNull(onCall);
// 8b. Exactly one BundleExported audit row (user=alice) survived the
// wipe? No — we deliberately wiped audit rows along with the
// entities. The export-side audit row was written BEFORE the
// wipe. Post-import there should therefore be zero
// BundleExported rows from before, plus none from the import
// (the importer doesn't emit BundleExported). The round-trip's
// export-side identity therefore lives ONLY in the bundle bytes
// we captured — manifestContentHash is the export's hash, and
// the BundleImported row's AfterStateJson echoes it back below.
// So this assertion targets the BundleImported side only.
var imported = await ctx.AuditLogEntries
.SingleAsync(a => a.Action == "BundleImported");
Assert.Equal("bob", imported.User);
Assert.Equal("Bundle", imported.EntityType);
Assert.Equal(result.BundleImportId, imported.BundleImportId);
Assert.NotNull(imported.AfterStateJson);
// Round-trip identity: the import row carries the same ContentHash
// we read from the bundle bytes above. That's the proof that the
// export's bundle is exactly what the import consumed.
Assert.Contains(manifestContentHash, imported.AfterStateJson!, StringComparison.Ordinal);
// 8c. Every per-entity audit row written during the import run
// carries result.BundleImportId — that's the correlation
// contract from T5/T17 applied end-to-end here.
var importRows = await ctx.AuditLogEntries
.Where(a => a.BundleImportId == result.BundleImportId)
.ToListAsync();
Assert.NotEmpty(importRows);
Assert.All(importRows, row =>
Assert.Equal(result.BundleImportId, row.BundleImportId));
// We seeded 3 templates + 1 shared script + 1 external system + 1
// notification list = 6 created artifacts, plus the BundleImported
// summary row = at least 7 stamped rows.
Assert.True(importRows.Count >= 7,
$"Expected at least 7 BundleImportId-stamped rows; got {importRows.Count}.");
}
// 8d. ImportResult counts line up with what we seeded.
Assert.Equal(6, result.Added);
Assert.Equal(0, result.Overwritten);
Assert.Equal(0, result.Skipped);
Assert.NotEqual(Guid.Empty, result.BundleImportId);
}
}