Files
Joseph Doherty b104760b3a feat(auth)!: ScadaBridge canonical roles + SoD collapse (Audit→Administrator, AuditReadOnly→Viewer) + config-DB migration (Task 1.7)
Standardize role string VALUES on the canonical vocabulary
(Administrator/Designer/Deployer/Viewer; Operator/Engineer unused here):
  Admin        -> Administrator
  Design       -> Designer
  Deployment   -> Deployer
  Audit        -> Administrator   (COLLAPSE; accepted privilege escalation)
  AuditReadOnly-> Viewer          (COLLAPSE; keeps audit-read, no export)

SoD: OperationalAuditRoles = { Administrator, Viewer },
     AuditExportRoles      = { Administrator }
so Viewer reads the audit log + nav but cannot bulk-export, while
Administrator does both + holds the full admin surface (the documented,
accepted auditor/admin SoD collapse).

Atomic move across every enforcement site:
- Roles constants; AuthorizationPolicies (RequireClaim values + SoD arrays +
  honest XML-doc); RoleMapper Deployer check.
- ManagementActor.GetRequiredRole switch + the hard-coded site-scope
  admin-bypass (now Roles.Administrator at all 6 sites). Site-scoping logic
  is otherwise unchanged.
- DebugStreamHub Administrator/Deployer gates (Deployer kept case-sensitive).
- CentralUI BrowseService/BindingTester Designer guards; LdapMappingForm
  dropdown now offers canonical values (incl. Viewer).
- Config-DB seed (LdapGroupMappings Id 1-4) + EF migration CanonicalizeRoles:
  Id-keyed UpdateData for seed rows + idempotent raw catch-all UPDATEs for
  operator-added rows. Down is lossy on the collapse (documented in-file).
  No pending model changes.

Tests reworked to the collapsed model across Security/CentralUI/
ManagementService/ConfigurationDatabase/Integration suites, incl. explicit
Viewer-reads-not-exports and former-Audit-now-Administrator-escalation cases.

CHANGELOG: BREAKING security note documenting the canonicalization + SoD
collapse.
2026-06-02 08:00:47 -04:00

439 lines
16 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
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.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
public class DbContextTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
public DbContextTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
}
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
[Fact]
public void Schema_CreatesAllTables()
{
// Verify all DbSet tables exist by checking we can query them without error
Assert.NotNull(_context.Templates);
Assert.NotNull(_context.TemplateAttributes);
Assert.NotNull(_context.TemplateAlarms);
Assert.NotNull(_context.TemplateScripts);
Assert.NotNull(_context.TemplateCompositions);
Assert.NotNull(_context.Instances);
Assert.NotNull(_context.InstanceAttributeOverrides);
Assert.NotNull(_context.InstanceConnectionBindings);
Assert.NotNull(_context.Areas);
Assert.NotNull(_context.Sites);
Assert.NotNull(_context.DataConnections);
Assert.NotNull(_context.DeploymentRecords);
Assert.NotNull(_context.SystemArtifactDeploymentRecords);
Assert.NotNull(_context.ExternalSystemDefinitions);
Assert.NotNull(_context.ExternalSystemMethods);
Assert.NotNull(_context.DatabaseConnectionDefinitions);
Assert.NotNull(_context.NotificationLists);
Assert.NotNull(_context.NotificationRecipients);
Assert.NotNull(_context.SmtpConfigurations);
Assert.NotNull(_context.SharedScripts);
Assert.NotNull(_context.LdapGroupMappings);
Assert.NotNull(_context.SiteScopeRules);
// Auth re-arch (C5): the ApiKeys DbSet was retired (inbound keys moved to the
// shared ZB.MOM.WW.Auth.ApiKeys SQLite store); only ApiMethods remains.
Assert.NotNull(_context.ApiMethods);
Assert.NotNull(_context.AuditLogEntries);
// Verify we can enumerate all tables (schema is valid)
Assert.Empty(_context.Templates.ToList());
Assert.Empty(_context.Sites.ToList());
Assert.Empty(_context.Instances.ToList());
Assert.Empty(_context.AuditLogEntries.ToList());
}
[Fact]
public void Template_WithChildren_CascadeCreated()
{
var template = new Template("TestTemplate")
{
Description = "A test template"
};
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Int32 });
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.ValueMatch, PriorityLevel = 1 });
template.Scripts.Add(new TemplateScript("Script1", "return 42;"));
_context.Templates.Add(template);
_context.SaveChanges();
var loaded = _context.Templates
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Single(t => t.Name == "TestTemplate");
Assert.Single(loaded.Attributes);
Assert.Single(loaded.Alarms);
Assert.Single(loaded.Scripts);
Assert.Equal("Attr1", loaded.Attributes.First().Name);
}
[Fact]
public void Template_Inheritance_SelfReference()
{
var parent = new Template("ParentTemplate");
_context.Templates.Add(parent);
_context.SaveChanges();
var child = new Template("ChildTemplate") { ParentTemplateId = parent.Id };
_context.Templates.Add(child);
_context.SaveChanges();
var loaded = _context.Templates.Single(t => t.Name == "ChildTemplate");
Assert.Equal(parent.Id, loaded.ParentTemplateId);
}
[Fact]
public void Template_Composition_CreatesRelationship()
{
var composedTemplate = new Template("ComposedTemplate");
var parentTemplate = new Template("ParentTemplate");
_context.Templates.AddRange(composedTemplate, parentTemplate);
_context.SaveChanges();
parentTemplate.Compositions.Add(new TemplateComposition("Module1") { ComposedTemplateId = composedTemplate.Id });
_context.SaveChanges();
var loaded = _context.Templates
.Include(t => t.Compositions)
.Single(t => t.Name == "ParentTemplate");
Assert.Single(loaded.Compositions);
Assert.Equal(composedTemplate.Id, loaded.Compositions.First().ComposedTemplateId);
}
[Fact]
public void Instance_WithOverridesAndBindings()
{
var site = new Site("Site1", "SITE-001");
var template = new Template("Template1");
_context.Sites.Add(site);
_context.Templates.Add(template);
_context.SaveChanges();
var dataConn = new DataConnection("OpcConn", "OpcUa", site.Id);
_context.DataConnections.Add(dataConn);
_context.SaveChanges();
var instance = new Instance("Instance1")
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Attr1") { OverrideValue = "42" });
instance.ConnectionBindings.Add(new InstanceConnectionBinding("TagPath") { DataConnectionId = dataConn.Id });
_context.Instances.Add(instance);
_context.SaveChanges();
var loaded = _context.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.ConnectionBindings)
.Single(i => i.UniqueName == "Instance1");
Assert.Single(loaded.AttributeOverrides);
Assert.Single(loaded.ConnectionBindings);
Assert.Equal(InstanceState.Enabled, loaded.State);
}
[Fact]
public void DeploymentRecord_CreatesWithAllFields()
{
var site = new Site("Site1", "SITE-001");
var template = new Template("Template1");
_context.Sites.Add(site);
_context.Templates.Add(template);
_context.SaveChanges();
var instance = new Instance("Instance1") { TemplateId = template.Id, SiteId = site.Id, State = InstanceState.Enabled };
_context.Instances.Add(instance);
_context.SaveChanges();
var record = new DeploymentRecord("deploy-001", "admin")
{
InstanceId = instance.Id,
Status = DeploymentStatus.Success,
RevisionHash = "abc123",
DeployedAt = DateTimeOffset.UtcNow,
CompletedAt = DateTimeOffset.UtcNow
};
_context.DeploymentRecords.Add(record);
_context.SaveChanges();
var loaded = _context.DeploymentRecords.Single(d => d.DeploymentId == "deploy-001");
Assert.Equal(DeploymentStatus.Success, loaded.Status);
Assert.Equal("abc123", loaded.RevisionHash);
}
[Fact]
public void AuditLogEntry_WritesAndQueries()
{
var entry = new AuditLogEntry("admin", "Create", "Template", "1", "TestTemplate")
{
Timestamp = DateTimeOffset.UtcNow,
AfterStateJson = "{\"name\":\"TestTemplate\"}"
};
_context.AuditLogEntries.Add(entry);
_context.SaveChanges();
var loaded = _context.AuditLogEntries.Single(a => a.User == "admin");
Assert.Equal("Create", loaded.Action);
Assert.Equal("Template", loaded.EntityType);
Assert.NotNull(loaded.AfterStateJson);
}
[Fact]
public void ExternalSystem_WithMethods()
{
var system = new ExternalSystemDefinition("ERP", "https://erp.example.com/api", "ApiKey")
{
MaxRetries = 3,
RetryDelay = TimeSpan.FromSeconds(5)
};
_context.ExternalSystemDefinitions.Add(system);
_context.SaveChanges();
var method = new ExternalSystemMethod("GetOrder", "GET", "/orders/{id}")
{
ExternalSystemDefinitionId = system.Id
};
_context.ExternalSystemMethods.Add(method);
_context.SaveChanges();
Assert.Single(_context.ExternalSystemMethods.Where(m => m.ExternalSystemDefinitionId == system.Id));
}
[Fact]
public void NotificationList_WithRecipients()
{
var list = new NotificationList("Operators");
list.Recipients.Add(new NotificationRecipient("John", "john@example.com"));
list.Recipients.Add(new NotificationRecipient("Jane", "jane@example.com"));
_context.NotificationLists.Add(list);
_context.SaveChanges();
var loaded = _context.NotificationLists
.Include(n => n.Recipients)
.Single(n => n.Name == "Operators");
Assert.Equal(2, loaded.Recipients.Count);
}
[Fact]
public void Security_LdapGroupMapping_WithSiteScopeRules()
{
var site = new Site("Site1", "SITE-001");
_context.Sites.Add(site);
_context.SaveChanges();
var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Administrator");
_context.LdapGroupMappings.Add(mapping);
_context.SaveChanges();
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
_context.SiteScopeRules.Add(rule);
_context.SaveChanges();
Assert.Single(_context.SiteScopeRules.Where(r => r.LdapGroupMappingId == mapping.Id));
}
[Fact]
public void InboundApi_Method()
{
// Auth re-arch (C5): the SQL Server ApiKey entity was retired (inbound keys
// now live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store), so this case
// covers only the surviving ApiMethod catalogue.
var method = new ApiMethod("GetStatus", "return \"ok\";") { TimeoutSeconds = 30 };
_context.ApiMethods.Add(method);
_context.SaveChanges();
Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus"));
}
[Fact]
public void Area_HierarchyWorks()
{
var site = new Site("Site1", "SITE-001");
_context.Sites.Add(site);
_context.SaveChanges();
var parentArea = new Area("Building A") { SiteId = site.Id };
_context.Areas.Add(parentArea);
_context.SaveChanges();
var childArea = new Area("Floor 1") { SiteId = site.Id, ParentAreaId = parentArea.Id };
_context.Areas.Add(childArea);
_context.SaveChanges();
var loaded = _context.Areas
.Include(a => a.Children)
.Single(a => a.Name == "Building A");
Assert.Single(loaded.Children);
Assert.Equal("Floor 1", loaded.Children.First().Name);
}
[Fact]
public void DataConnection_BelongsToSite()
{
var site = new Site("Site1", "SITE-001");
_context.Sites.Add(site);
_context.SaveChanges();
var conn = new DataConnection("OpcConn", "OpcUa", site.Id);
_context.DataConnections.Add(conn);
_context.SaveChanges();
var loaded = _context.DataConnections.Single(c => c.Name == "OpcConn");
Assert.Equal(site.Id, loaded.SiteId);
}
[Fact]
public void EnumProperties_StoredAsStrings()
{
var template = new Template("EnumTest");
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Double });
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.RangeViolation, PriorityLevel = 1 });
_context.Templates.Add(template);
_context.SaveChanges();
// Query using raw SQL to verify string storage
var attr = _context.TemplateAttributes.Single(a => a.Name == "Attr1");
Assert.Equal(DataType.Double, attr.DataType);
var alarm = _context.TemplateAlarms.Single(a => a.Name == "Alarm1");
Assert.Equal(AlarmTriggerType.RangeViolation, alarm.TriggerType);
}
[Fact]
public void UniqueConstraint_Template_Name_Enforced()
{
_context.Templates.Add(new Template("Unique"));
_context.SaveChanges();
_context.Templates.Add(new Template("Unique"));
Assert.ThrowsAny<Exception>(() => _context.SaveChanges());
}
[Fact]
public void DateTimeOffset_MappedCorrectly()
{
var now = DateTimeOffset.UtcNow;
var entry = new AuditLogEntry("user", "Test", "Entity", "1", "Name") { Timestamp = now };
_context.AuditLogEntries.Add(entry);
_context.SaveChanges();
_context.ChangeTracker.Clear();
var loaded = _context.AuditLogEntries.Single();
// SQLite has limited DateTimeOffset precision, but the round-trip should preserve the value within a second
Assert.True(Math.Abs((loaded.Timestamp - now).TotalSeconds) < 1);
}
}
public class ServiceRegistrationTests
{
[Fact]
public void AddConfigurationDatabase_WithConnectionString_RegistersDbContext()
{
var services = new ServiceCollection();
services.AddConfigurationDatabase("DataSource=:memory:");
var provider = services.BuildServiceProvider();
var context = provider.GetService<ScadaBridgeDbContext>();
Assert.NotNull(context);
}
[Fact]
public void AddConfigurationDatabase_NoArgs_FailsFast()
{
// ConfigurationDatabase-003: the no-arg overload previously silently registered
// nothing, which deferred a misconfiguration into an opaque DI failure later.
// It is now [Obsolete(error: true)] (compile-time guard) and throws at runtime.
// Invoked via reflection because the obsolete-error overload cannot be called
// directly from source.
var method = typeof(ServiceCollectionExtensions).GetMethod(
nameof(ServiceCollectionExtensions.AddConfigurationDatabase),
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static,
binder: null,
types: new[] { typeof(IServiceCollection) },
modifiers: null)!;
var services = new ServiceCollection();
var invocation = Assert.Throws<System.Reflection.TargetInvocationException>(
() => method.Invoke(null, new object[] { services }));
Assert.IsType<InvalidOperationException>(invocation.InnerException);
}
}
public class MigrationHelperTests : IDisposable
{
private readonly ScadaBridgeDbContext _context;
public MigrationHelperTests()
{
// Use SQLite with PendingModelChangesWarning suppressed because the migration
// was generated for SQL Server and SQLite's model representation differs slightly.
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlite("DataSource=:memory:")
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
.Options;
_context = new ScadaBridgeDbContext(options);
_context.Database.OpenConnection();
}
public void Dispose()
{
_context.Database.CloseConnection();
_context.Dispose();
}
[Fact]
public async Task ApplyOrValidate_ProductionMode_WithPendingMigrations_Throws()
{
// Database has no schema yet, so pending migrations exist.
// The production path uses GetPendingMigrationsAsync which works cross-provider.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => MigrationHelper.ApplyOrValidateMigrationsAsync(_context, isDevelopment: false));
Assert.Contains("pending migration", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MigrationExists_InitialCreate()
{
// Verify the InitialCreate migration is detected as pending
var pending = _context.Database.GetPendingMigrations().ToList();
Assert.Contains(pending, m => m.Contains("InitialSchema"));
}
}