feat(auth): ScadaBridge TransportExport excludes inbound API keys (re-arch C4; methods-only, import ignores legacy key sections); keys re-issued per environment

This commit is contained in:
Joseph Doherty
2026-06-02 05:06:40 -04:00
parent d1191fddf9
commit 731cfd3bfc
34 changed files with 212 additions and 190 deletions
@@ -118,7 +118,8 @@ public class TransportExportPageTests : BunitContext
var db = new DatabaseConnectionDefinition("Hist", "Server=.;") { Id = 30 };
var notifList = new NotificationList("Ops") { Id = 40 };
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "no-reply@example.com") { Id = 50 };
var apiKey = new ApiKey("ext-system", "key-hash") { Id = 60 };
// Inbound API keys are not transported between environments (re-arch C4) — the
// export page no longer offers a keys selection list, only API methods.
var apiMethod = new ApiMethod("CreateOrder", "// noop") { Id = 70 };
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
@@ -135,15 +136,14 @@ public class TransportExportPageTests : BunitContext
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList> { notifList }));
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration> { smtp }));
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey> { apiKey }));
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod> { apiMethod }));
var cut = Render<TransportExportPage>();
cut.WaitForState(() => cut.Markup.Contains("Pump"));
// All six flat groups (plus templates) are present.
// All flat groups (plus templates) are present. There is intentionally NO
// API-keys group: inbound API keys are not transported (re-arch C4).
foreach (var groupId in new[]
{
"group-templates",
@@ -152,20 +152,23 @@ public class TransportExportPageTests : BunitContext
"group-db-connections",
"group-notification-lists",
"group-smtp-configs",
"group-api-keys",
"group-api-methods",
})
{
Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
}
// The API-keys selection group is gone, replaced by an info note explaining
// that keys must be re-created per environment.
Assert.Empty(cut.FindAll("[data-testid='group-api-keys']"));
Assert.NotNull(cut.Find("[data-testid='api-keys-not-transported']"));
// Sanity: each artifact shows its label.
Assert.Contains("Helpers", cut.Markup);
Assert.Contains("ERP", cut.Markup);
Assert.Contains("Hist", cut.Markup);
Assert.Contains("Ops", cut.Markup);
Assert.Contains("smtp.example.com", cut.Markup);
Assert.Contains("ext-system", cut.Markup);
Assert.Contains("CreateOrder", cut.Markup);
// Next button is disabled while no selection exists.
@@ -92,7 +92,7 @@ public class TransportImportPageTests : BunitContext
Iterations: 600_000,
SaltB64: "abc",
IvB64: "def"),
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>()),
DecryptedContent = Array.Empty<byte>(),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
@@ -31,7 +31,7 @@ public sealed class TransportRecordsTests
var summary = new BundleSummary(
Templates: 2, TemplateFolders: 1, SharedScripts: 3,
ExternalSystems: 1, DbConnections: 0, NotificationLists: 1,
SmtpConfigs: 1, ApiKeys: 0, ApiMethods: 4);
SmtpConfigs: 1, ApiMethods: 4);
var contents = new List<ManifestContentEntry>
{
new("Template", "Pump", 1, new List<string> { "Shared.Helpers" }),
@@ -71,7 +71,7 @@ public sealed class TransportRecordsTests
Iterations: 600_000,
SaltB64: "c2FsdA==",
IvB64: "aXY=");
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0);
var summary = new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0);
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
@@ -116,7 +116,6 @@ public sealed class TransportRecordsTests
DatabaseConnectionIds: new[] { 20, 21 },
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: new[] { 30 },
ApiKeyIds: new[] { 40, 41 },
ApiMethodIds: new[] { 50 },
IncludeDependencies: true);
@@ -137,7 +136,6 @@ public sealed class TransportRecordsTests
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
@@ -1220,7 +1220,7 @@ public class ManagementActorTests : TestKit, IDisposable
TemplateNames: null, SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null,
ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
@@ -1283,7 +1283,7 @@ public class ManagementActorTests : TestKit, IDisposable
SharedScriptNames: null,
ExternalSystemNames: null, DatabaseConnectionNames: null,
NotificationListNames: null, SmtpConfigurationNames: null,
ApiKeyNames: null, ApiMethodNames: null,
ApiMethodNames: null,
IncludeDependencies: false, Passphrase: null,
SourceEnvironment: "test-env");
@@ -69,7 +69,6 @@ public sealed class CompositionImportTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
@@ -70,7 +70,6 @@ public sealed class ConflictResolutionTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
@@ -132,7 +132,6 @@ public sealed class BundleExporterTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
@@ -222,7 +221,6 @@ public sealed class BundleExporterTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
@@ -15,6 +15,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -96,7 +97,6 @@ public sealed class BundleImporterApplyTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
@@ -747,4 +747,96 @@ public sealed class BundleImporterApplyTests : IDisposable
}
Assert.Equal(1, result.Overwritten);
}
// ─────────────────────────────────────────────────────────────────────
// 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 — no keys created, the method WAS created, the ignored count is
// surfaced, and the import did not fault.
await using (var scope = _provider.CreateAsyncScope())
{
var inboundRepo = scope.ServiceProvider.GetRequiredService<IInboundApiRepository>();
var keys = await inboundRepo.GetAllApiKeysAsync();
Assert.Empty(keys);
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);
}
}
@@ -61,7 +61,6 @@ public sealed class BundleImporterPreviewTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
@@ -181,7 +181,6 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
@@ -122,7 +122,6 @@ public sealed class RoundTripTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: notificationListIds,
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
@@ -81,7 +81,6 @@ public sealed class SemanticValidatorImportTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
@@ -84,7 +84,6 @@ public sealed class ValidationFailureTests : IDisposable
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: false);
@@ -25,7 +25,6 @@ public sealed class DependencyResolverTests
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
@@ -36,7 +35,6 @@ public sealed class DependencyResolverTests
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: ids,
IncludeDependencies: true);
@@ -43,7 +43,6 @@ public sealed class BundleImporterLoadTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private static BundleContentDto SmallContent() => new(
@@ -65,7 +64,6 @@ public sealed class BundleImporterLoadTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private sealed class TestTimeProvider : TimeProvider
@@ -132,7 +130,7 @@ public sealed class BundleImporterLoadTests
exportedBy: "alice",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase: null, encryptor: null);
@@ -154,7 +152,7 @@ public sealed class BundleImporterLoadTests
exportedBy: "alice",
scadaBridgeVersion: "1.0.0",
encryption: seed,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase, encryptor);
@@ -450,7 +448,7 @@ public sealed class BundleImporterLoadTests
ScadaBridgeVersion: "1.0.0",
ContentHash: "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(),
Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>());
var bundleStream = HandCraftZip(forwardManifest, contentBytes, encrypted: false);
@@ -473,7 +471,7 @@ public sealed class BundleImporterLoadTests
exportedBy: "alice",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0),
summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: originalContentBytes);
@@ -26,7 +26,7 @@ public sealed class BundleSessionStoreTests
ScadaBridgeVersion: "1",
ContentHash: "0",
Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>());
[Fact]
@@ -21,7 +21,6 @@ public sealed class BundleSerializerTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private static BundleManifest BuildManifestFor(byte[] contentBytes, EncryptionMetadata? encryption = null) =>
@@ -30,7 +29,7 @@ public sealed class BundleSerializerTests
exportedBy: "tester",
scadaBridgeVersion: "1.0.0",
encryption: encryption,
summary: new BundleSummary(0, 1, 1, 0, 0, 0, 0, 0, 0),
summary: new BundleSummary(0, 1, 1, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
@@ -19,7 +19,6 @@ public sealed class EntitySerializerTests
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
NotificationLists: Array.Empty<NotificationList>(),
SmtpConfigurations: Array.Empty<SmtpConfiguration>(),
ApiKeys: Array.Empty<ApiKey>(),
ApiMethods: Array.Empty<ApiMethod>());
[Fact]
@@ -217,7 +216,6 @@ public sealed class EntitySerializerTests
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
var aggregate = new EntitySerializer().FromBundleContent(dto);
@@ -7,14 +7,14 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
public sealed class ManifestBuilderTests
{
private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0, 0);
private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0);
private static IReadOnlyList<ManifestContentEntry> NoContents => Array.Empty<ManifestContentEntry>();
[Fact]
public void Build_populates_summary_from_contents()
{
var sut = new ManifestBuilder();
var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0, 0);
var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0);
var contents = new[]
{
new ManifestContentEntry("Template", "T1", 1, Array.Empty<string>()),