From d9c99242a3dc9202e71fda48524c2c484a4b6555 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 10:25:25 -0400 Subject: [PATCH] feat(configdb): add AuditLog migration with monthly partitioning and DB roles (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle C of the #23 M1 foundation. Creates the centralized AuditLog table with the partition function, partition scheme, partition-aligned non-clustered indexes, and the two access-control roles documented in alog.md §4. Schema: - pf_AuditLog_Month: RANGE RIGHT, 24 monthly boundaries (Jan 2026 – Dec 2027). - ps_AuditLog_Month: ALL TO ([PRIMARY]) — dev/test parity. - dbo.AuditLog: created via raw SQL ON ps_AuditLog_Month(OccurredAtUtc). Composite clustered PK {EventId, OccurredAtUtc} (partition column must be part of the clustered key). 22 columns matching the EF AuditEvent model. - 5 reconciliation/query non-clustered indexes from alog.md §4 (Channel_Status_Occurred, CorrelationId filtered, OccurredAtUtc, Site_Occurred, Target_Occurred filtered) — all partition-aligned. - UX_AuditLog_EventId: non-aligned UNIQUE on EventId alone (preserves InsertIfNotExistsAsync idempotency from M1-T8). Non-aligned because partition-aligned unique indexes require the partition column in the key, which would weaken to composite uniqueness; the purge story (M2/M3) rebuilds this index around partition switches. Access control: - scadalink_audit_writer: GRANT INSERT + GRANT SELECT, DENY UPDATE + DENY DELETE on AuditLog. The explicit DENY guarantees later db_datawriter membership cannot quietly re-enable mutation. - scadalink_audit_purger: GRANT SELECT on AuditLog, GRANT ALTER on SCHEMA::dbo (enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION). Both role definitions are idempotent (IF DATABASE_PRINCIPAL_ID IS NULL). Down() drops in reverse dependency order with IF EXISTS guards. Integration tests (tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/): - MsSqlMigrationFixture: connects to the running infra/mssql container (or the SCADALINK_MSSQL_TEST_CONN override), creates a unique per-fixture database, applies the migrations, drops the DB on dispose. Marks itself Available=false when MSSQL is unreachable so tests early-return cleanly on CI without the dev container. - AddAuditLogTableMigrationTests: 8 tests covering table existence, partition function/scheme, partition-aligned PK, the 5 named indexes, both roles' grants, and a smoke test that a writer-role user receives SqlException with "permission" on UPDATE AuditLog. ConfigurationDatabase tests: 142 passing -> 150 passing (8 new integration tests). Full solution builds clean. Package: tests project locally overrides Microsoft.Data.SqlClient to 6.1.1 (EF SqlServer 10.0.7 needs >= 6.1.1; central package version is pinned at 6.0.2 for the production ExternalSystemGateway). --- ...0260520142214_AddAuditLogTable.Designer.cs | 1553 +++++++++++++++++ .../20260520142214_AddAuditLogTable.cs | 201 +++ .../ScadaLinkDbContextModelSnapshot.cs | 117 ++ .../AddAuditLogTableMigrationTests.cs | 285 +++ .../Migrations/MsSqlMigrationFixture.cs | 192 ++ ...adaLink.ConfigurationDatabase.Tests.csproj | 9 + 6 files changed, 2357 insertions(+) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs new file mode 100644 index 0000000..0d465d5 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs @@ -0,0 +1,1553 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260520142214_AddAuditLogTable")] + partial class AddAuditLogTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs new file mode 100644 index 0000000..9520efe --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs @@ -0,0 +1,201 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Bundle C (#23 M1): creates the centralized AuditLog table with monthly + /// partitioning and the two access-control roles documented in alog.md §4. + /// + /// Structure: + /// 1. Partition function pf_AuditLog_Month (RANGE RIGHT) with 24 + /// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC. + /// 2. Partition scheme ps_AuditLog_Month mapping every partition to + /// [PRIMARY] (dev/test parity; production may relocate via filegroups). + /// 3. AuditLog table created via raw SQL so it is created directly + /// on the partition scheme. The clustered PK is composite + /// {EventId, OccurredAtUtc} — required because partition-aligned PKs + /// must include the partition column. + /// 4. Five reconciliation/query indexes from alog.md §4, plus the + /// UX_AuditLog_EventId unique index that preserves single-column + /// EventId uniqueness for InsertIfNotExistsAsync (M1-T8). All + /// non-clustered indexes are partition-aligned on + /// ps_AuditLog_Month(OccurredAtUtc). + /// 5. Two database roles: + /// - scadalink_audit_writer: INSERT + SELECT on AuditLog, with + /// explicit DENY on UPDATE and DELETE so additive role membership + /// (e.g. later db_datawriter) cannot accidentally re-enable mutation. + /// - scadalink_audit_purger: SELECT on AuditLog and ALTER on + /// SCHEMA::dbo so the purger can run ALTER PARTITION FUNCTION SWITCH + /// and SWITCH PARTITION when sliding the retention window. + /// + public partial class AddAuditLogTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1) Partition function (monthly boundaries Jan 2026 – Dec 2027 UTC). + // RANGE RIGHT — the boundary value belongs to the right-hand partition, + // matching the convention used by SQL Server partition-switch tooling. + migrationBuilder.Sql(@" +CREATE PARTITION FUNCTION pf_AuditLog_Month (datetime2(7)) +AS RANGE RIGHT FOR VALUES ( + '2026-01-01T00:00:00', '2026-02-01T00:00:00', '2026-03-01T00:00:00', '2026-04-01T00:00:00', + '2026-05-01T00:00:00', '2026-06-01T00:00:00', '2026-07-01T00:00:00', '2026-08-01T00:00:00', + '2026-09-01T00:00:00', '2026-10-01T00:00:00', '2026-11-01T00:00:00', '2026-12-01T00:00:00', + '2027-01-01T00:00:00', '2027-02-01T00:00:00', '2027-03-01T00:00:00', '2027-04-01T00:00:00', + '2027-05-01T00:00:00', '2027-06-01T00:00:00', '2027-07-01T00:00:00', '2027-08-01T00:00:00', + '2027-09-01T00:00:00', '2027-10-01T00:00:00', '2027-11-01T00:00:00', '2027-12-01T00:00:00' +);"); + + // 2) Partition scheme mapping every partition to [PRIMARY]. + migrationBuilder.Sql(@" +CREATE PARTITION SCHEME ps_AuditLog_Month +AS PARTITION pf_AuditLog_Month ALL TO ([PRIMARY]);"); + + // 3) Create the table directly on the partition scheme. Column shapes + // are copied from AuditLogEntityTypeConfiguration so the live schema + // matches the EF model exactly. The clustered PK is composite to + // satisfy SQL Server's rule that partition-aligned clustered indexes + // must include the partition column. + migrationBuilder.Sql(@" +CREATE TABLE dbo.AuditLog ( + EventId uniqueidentifier NOT NULL, + OccurredAtUtc datetime2(7) NOT NULL, + IngestedAtUtc datetime2(7) NULL, + Channel varchar(32) NOT NULL, + Kind varchar(32) NOT NULL, + CorrelationId uniqueidentifier NULL, + SourceSiteId varchar(64) NULL, + SourceInstanceId varchar(128) NULL, + SourceScript varchar(128) NULL, + Actor varchar(128) NULL, + Target varchar(256) NULL, + Status varchar(32) NOT NULL, + HttpStatus int NULL, + DurationMs int NULL, + ErrorMessage nvarchar(1024) NULL, + ErrorDetail nvarchar(max) NULL, + RequestSummary nvarchar(max) NULL, + ResponseSummary nvarchar(max) NULL, + PayloadTruncated bit NOT NULL, + Extra nvarchar(max) NULL, + ForwardState varchar(32) NULL, + CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) + ON ps_AuditLog_Month(OccurredAtUtc) +) ON ps_AuditLog_Month(OccurredAtUtc);"); + + // 4) Reconciliation/query indexes from alog.md §4. All non-clustered + // indexes are partition-aligned on ps_AuditLog_Month(OccurredAtUtc) + // so partition-switch operations only touch a single partition. The + // filtered indexes carry their NOT NULL predicates as documented. + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc +ON dbo.AuditLog (OccurredAtUtc DESC) +ON ps_AuditLog_Month(OccurredAtUtc);"); + + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred +ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC) +ON ps_AuditLog_Month(OccurredAtUtc);"); + + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId +ON dbo.AuditLog (CorrelationId) +WHERE CorrelationId IS NOT NULL +ON ps_AuditLog_Month(OccurredAtUtc);"); + + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred +ON dbo.AuditLog (Channel ASC, Status ASC, OccurredAtUtc DESC) +ON ps_AuditLog_Month(OccurredAtUtc);"); + + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred +ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC) +WHERE Target IS NOT NULL +ON ps_AuditLog_Month(OccurredAtUtc);"); + + // The EventId uniqueness index supports InsertIfNotExistsAsync + // (M1-T8). It is INTENTIONALLY non-aligned (placed on [PRIMARY] + // rather than ps_AuditLog_Month). + // + // SQL Server's rule for unique partition-aligned indexes is that the + // partition column must be a SUBSET of the index key. Including + // OccurredAtUtc in the key would change the uniqueness semantics + // from "EventId is globally unique" to "(EventId, OccurredAtUtc) + // is unique", which is the same guarantee the composite PK already + // provides — it would not give us single-column EventId uniqueness. + // + // Trade-off: a non-aligned index disables ALTER TABLE … SWITCH + // PARTITION on AuditLog. The M1 purge story (M2/M3) uses an + // explicit rebuild path that drops and re-creates this index + // around the switch, so the aligned-indexes pattern is preserved + // for partition switching at purge time. + migrationBuilder.Sql(@" +CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId +ON dbo.AuditLog (EventId) +ON [PRIMARY];"); + + // 5) DB roles. Both definitions are idempotent so the migration is + // safe to re-apply against a database that already has the role. + // The DENY UPDATE / DENY DELETE on the writer role is deliberate — + // a future db_datawriter membership cannot quietly re-enable + // mutation because DENY outranks GRANT. + migrationBuilder.Sql(@" +IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NULL + EXEC sp_executesql N'CREATE ROLE scadalink_audit_writer'; +GRANT INSERT ON dbo.AuditLog TO scadalink_audit_writer; +GRANT SELECT ON dbo.AuditLog TO scadalink_audit_writer; +DENY UPDATE ON dbo.AuditLog TO scadalink_audit_writer; +DENY DELETE ON dbo.AuditLog TO scadalink_audit_writer;"); + + migrationBuilder.Sql(@" +IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NULL + EXEC sp_executesql N'CREATE ROLE scadalink_audit_purger'; +GRANT SELECT ON dbo.AuditLog TO scadalink_audit_purger; +GRANT ALTER ON SCHEMA::dbo TO scadalink_audit_purger;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Drop in reverse dependency order so each statement's prerequisites + // still exist when it runs. Each DROP is guarded so a partial Up() + // (or a re-applied Down()) cannot fail on missing objects. + migrationBuilder.Sql(@" +IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NOT NULL + EXEC sp_executesql N'DROP ROLE scadalink_audit_purger'; +IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NOT NULL + EXEC sp_executesql N'DROP ROLE scadalink_audit_writer';"); + + // Indexes are dropped implicitly when the table goes away, but + // dropping them explicitly first keeps the Down() statement self- + // describing and mirrors the Up() shape. + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog; +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Target_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog; +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Channel_Status_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog; +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_CorrelationId' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog; +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Site_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog; +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_OccurredAtUtc' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;"); + + migrationBuilder.Sql(@" +IF OBJECT_ID('dbo.AuditLog', 'U') IS NOT NULL + DROP TABLE dbo.AuditLog;"); + + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month') + DROP PARTITION SCHEME ps_AuditLog_Month; +IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month') + DROP PARTITION FUNCTION pf_AuditLog_Month;"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index e67197a..02b7745 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -41,6 +41,123 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.ToTable("DataProtectionKeys"); }); + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => { b.Property("Id") diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs new file mode 100644 index 0000000..73e2359 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs @@ -0,0 +1,285 @@ +using Microsoft.Data.SqlClient; +using Xunit.Abstractions; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// Bundle C (#23 M1) integration tests: applies the EF migrations to a +/// freshly-created MSSQL test database on the running infra/mssql container +/// and asserts that the AddAuditLogTable migration produced the expected +/// partition function, partition scheme, partition-aligned table, named +/// indexes, and DB roles. +/// +/// +/// Tests early-return with a clear test-output message when the +/// reports unavailable, so CI without +/// the dev MSSQL container still runs the suite green. +/// +public class AddAuditLogTableMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + private readonly ITestOutputHelper _output; + + public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + } + + [Fact] + public async Task AppliesMigration_CreatesAuditLogTable() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + var exists = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_NAME = 'AuditLog' AND TABLE_SCHEMA = 'dbo';"); + + Assert.Equal(1, exists); + } + + [Fact] + public async Task AppliesMigration_CreatesPartitionFunction_pf_AuditLog_Month() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + var functionExists = await ScalarAsync( + "SELECT COUNT(*) FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month';"); + Assert.Equal(1, functionExists); + + // Specification (alog.md §4 / Bundle C plan): 24 monthly boundaries + // covering 2026-01-01 through 2027-12-01 UTC. + var boundaryCount = await ScalarAsync( + "SELECT COUNT(*) FROM sys.partition_range_values rv " + + "INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id " + + "WHERE pf.name = 'pf_AuditLog_Month';"); + Assert.True(boundaryCount >= 24, + $"Expected at least 24 monthly boundaries on pf_AuditLog_Month; got {boundaryCount}."); + } + + [Fact] + public async Task AppliesMigration_CreatesPartitionScheme_ps_AuditLog_Month() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + var schemeExists = await ScalarAsync( + "SELECT COUNT(*) FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month';"); + Assert.Equal(1, schemeExists); + } + + [Fact] + public async Task AppliesMigration_TableIsPartitionAligned() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + // The clustered (PK) index on AuditLog must live on the ps_AuditLog_Month + // partition scheme; sys.indexes.data_space_id points at the scheme. + var schemeName = await ScalarAsync( + "SELECT ps.name FROM sys.indexes i " + + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + + "INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " + + "WHERE o.name = 'AuditLog' AND i.index_id = 1;"); + + Assert.Equal("ps_AuditLog_Month", schemeName); + } + + [Fact] + public async Task AppliesMigration_CreatesFiveNamedIndexes() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + var expected = new[] + { + "IX_AuditLog_OccurredAtUtc", + "IX_AuditLog_Site_Occurred", + "IX_AuditLog_CorrelationId", + "IX_AuditLog_Channel_Status_Occurred", + "IX_AuditLog_Target_Occurred", + }; + + foreach (var indexName in expected) + { + var count = await ScalarAsync( + "SELECT COUNT(*) FROM sys.indexes i " + + "INNER JOIN sys.objects o ON i.object_id = o.object_id " + + $"WHERE o.name = 'AuditLog' AND i.name = '{indexName}';"); + Assert.True(count == 1, $"Expected index '{indexName}' to exist on AuditLog; found {count}."); + } + } + + [Fact] + public async Task AppliesMigration_CreatesAuditWriterRole_WithExpectedGrants() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + var roleExists = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_principals " + + "WHERE name = 'scadalink_audit_writer' AND type = 'R';"); + Assert.Equal(1, roleExists); + + // GRANT INSERT + GRANT SELECT must be present (G state = grant). + var insertGranted = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_permissions p " + + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + + " AND p.permission_name = 'INSERT' AND p.state IN ('G','W');"); + Assert.Equal(1, insertGranted); + + var selectGranted = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_permissions p " + + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + + " AND p.permission_name = 'SELECT' AND p.state IN ('G','W');"); + Assert.Equal(1, selectGranted); + + // UPDATE / DELETE must NOT be granted — and DENY (state = 'D') is even + // stronger. Treat presence of GRANT (state 'G' or 'W') as the failure. + var updateGranted = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_permissions p " + + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + + " AND p.permission_name = 'UPDATE' AND p.state IN ('G','W');"); + Assert.Equal(0, updateGranted); + + var deleteGranted = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_permissions p " + + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + + "WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " + + " AND p.permission_name = 'DELETE' AND p.state IN ('G','W');"); + Assert.Equal(0, deleteGranted); + } + + [Fact] + public async Task AppliesMigration_CreatesAuditPurgerRole_WithExpectedGrants() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + var roleExists = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_principals " + + "WHERE name = 'scadalink_audit_purger' AND type = 'R';"); + Assert.Equal(1, roleExists); + + // SELECT on AuditLog. + var selectGranted = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_permissions p " + + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + + "INNER JOIN sys.objects o ON p.major_id = o.object_id " + + "WHERE pr.name = 'scadalink_audit_purger' AND o.name = 'AuditLog' " + + " AND p.permission_name = 'SELECT' AND p.state IN ('G','W');"); + Assert.Equal(1, selectGranted); + + // ALTER on SCHEMA::dbo (class 3 = SCHEMA). + var alterSchema = await ScalarAsync( + "SELECT COUNT(*) FROM sys.database_permissions p " + + "INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " + + "INNER JOIN sys.schemas s ON p.major_id = s.schema_id " + + "WHERE pr.name = 'scadalink_audit_purger' AND s.name = 'dbo' " + + " AND p.class = 3 AND p.permission_name = 'ALTER' AND p.state IN ('G','W');"); + Assert.Equal(1, alterSchema); + } + + [Fact] + public async Task AuditWriterRole_CannotUpdateAuditLog() + { + if (!await EnsureMigrationApplied()) + { + return; + } + + // Set up a dedicated user mapped to scadalink_audit_writer, then EXECUTE AS + // and attempt UPDATE — DENY UPDATE on the role must reject the statement. + // Use a guid-suffixed user name so reruns in the same fixture don't collide. + var testUser = $"audit_writer_smoke_{Guid.NewGuid():N}".Substring(0, 32); + + await using (var setup = new SqlConnection(_fixture.ConnectionString)) + { + await setup.OpenAsync(); + await using var setupCmd = setup.CreateCommand(); + setupCmd.CommandText = + $"CREATE USER [{testUser}] WITHOUT LOGIN; " + + $"ALTER ROLE scadalink_audit_writer ADD MEMBER [{testUser}];"; + await setupCmd.ExecuteNonQueryAsync(); + } + + var ex = await Assert.ThrowsAsync(async () => + { + await using var conn = new SqlConnection(_fixture.ConnectionString); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + // WHERE 1=0 guarantees no rows are touched even if the permission check + // somehow passes — the test asserts the engine rejects the statement + // at permission-check time, not via a side effect on data. + cmd.CommandText = + $"EXECUTE AS USER = '{testUser}'; " + + $"UPDATE dbo.AuditLog SET Status = 'X' WHERE 1 = 0; " + + $"REVERT;"; + await cmd.ExecuteNonQueryAsync(); + }); + + // SQL Server permission-denied errors carry number 229 (e.g. "The UPDATE + // permission was denied"). Assert the message mentions permission rather + // than pinning to the exact code, in case the engine version drifts. + Assert.Contains("permission", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // --- helpers ------------------------------------------------------------ + + /// + /// Applies the migration to the per-fixture test database. Returns false + /// when the fixture is unavailable (no MSSQL container) — callers should + /// log + early-return so the test is reported green on a CI box without + /// the dev container. + /// + private async Task EnsureMigrationApplied() + { + if (!_fixture.Available) + { + _output.WriteLine($"[SKIP] {_fixture.SkipReason}"); + return false; + } + + // ApplyAuditMigrationAsync is idempotent — repeat calls within a fixture + // are a no-op after the first migration. Cheaper than re-creating the + // database per test for M1. + await _fixture.ApplyAuditMigrationAsync(); + return true; + } + + private async Task ScalarAsync(string sql) + { + await using var conn = _fixture.OpenConnection(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = await cmd.ExecuteScalarAsync(); + if (result is null || result is DBNull) + { + return default!; + } + return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs new file mode 100644 index 0000000..cca6093 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/MsSqlMigrationFixture.cs @@ -0,0 +1,192 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// Per-test-class MSSQL fixture for the Bundle C integration tests (#23 M1). +/// +/// Creates a fresh, uniquely-named test database on the running infra/mssql +/// container, applies the EF migrations against it, and drops it on dispose. +/// When MSSQL is not reachable (CI without the container), +/// is set to false so each test can early-return cleanly — keeping the test +/// suite green wherever it runs. +/// +/// +/// xUnit 2.9.x has no dynamic Skip; the early-return-after-output pattern is +/// the project convention. Tests calling +/// receive a clear log line in the output explaining why they did not run. +/// +public sealed class MsSqlMigrationFixture : IDisposable +{ + // Same credentials infra/mssql/setup.sql + docker-compose use. Not a committed + // production secret — this is a local dev container connection string. + private const string DefaultAdminConnectionString = + "Server=localhost,1433;User Id=sa;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false"; + + private const string AdminEnvVar = "SCADALINK_MSSQL_TEST_CONN"; + + public string DatabaseName { get; } + + public string ConnectionString { get; } + + /// + /// True when the MSSQL container was reachable at fixture construction + /// time AND the per-fixture test database was successfully created. When + /// false, the integration tests using this fixture must early-return. + /// + public bool Available { get; } + + /// + /// Populated when is false; describes why the + /// fixture chose to skip (env var unset, connect failed, etc.). + /// + public string SkipReason { get; } + + private readonly string _adminConnectionString; + + public MsSqlMigrationFixture() + { + // Short, lowercase guid suffix keeps the database identifier under SQL Server's + // 128-char limit and safe for raw concatenation (no quoting required). + DatabaseName = $"ScadaLinkAuditMigTest_{Guid.NewGuid():N}".Substring(0, 38); + + // Env var lets CI / power users override the admin endpoint; absent + // defaults to the local docker dev container's sa connection. + var fromEnv = Environment.GetEnvironmentVariable(AdminEnvVar); + _adminConnectionString = string.IsNullOrWhiteSpace(fromEnv) + ? DefaultAdminConnectionString + : fromEnv; + + try + { + using var connection = new SqlConnection(_adminConnectionString); + // Short timeout so the suite skips quickly in a no-container environment + // rather than hanging on SqlClient's default 30s connect timeout. + connection.Open(); + + using var createCmd = connection.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];"; + createCmd.ExecuteNonQuery(); + + ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName); + Available = true; + SkipReason = string.Empty; + } + catch (Exception ex) + { + // Don't fail fixture construction — the surrounding test classes + // must remain runnable on a CI box without MSSQL. Each [Fact] gates + // on Available and skips with a clear reason via test output. + ConnectionString = string.Empty; + Available = false; + SkipReason = $"MSSQL not reachable at '{RedactPassword(_adminConnectionString)}': {ex.GetType().Name}: {ex.Message}"; + } + } + + /// + /// Applies the EF migrations to the per-fixture test database via a freshly + /// constructed pointed at it. Uses the + /// schema-only single-argument constructor — the AuditLog migration does + /// not write secret-bearing columns at apply time. + /// + public async Task ApplyAuditMigrationAsync(CancellationToken cancellationToken = default) + { + ThrowIfUnavailable(); + + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString) + .Options; + + await using var context = new ScadaLinkDbContext(options); + await context.Database.MigrateAsync(cancellationToken); + } + + /// + /// Convenience for opening a fresh to the test + /// database. Caller is responsible for disposal. + /// + public SqlConnection OpenConnection() + { + ThrowIfUnavailable(); + + var connection = new SqlConnection(ConnectionString); + connection.Open(); + return connection; + } + + public void Dispose() + { + if (!Available) + { + return; + } + + // Best-effort drop — never let a teardown failure pollute later runs. + // SINGLE_USER WITH ROLLBACK IMMEDIATE detaches lingering pooled connections + // so the DROP DATABASE doesn't fail with "database is in use". + try + { + // Connection-pool cleanup is necessary because EF's MigrateAsync leaves + // pooled connections behind; SqlConnection.ClearAllPools() forces them + // closed so the SINGLE_USER + DROP sequence below can complete. + SqlConnection.ClearAllPools(); + + using var connection = new SqlConnection(_adminConnectionString); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = + $"IF DB_ID(N'{DatabaseName}') IS NOT NULL " + + $"BEGIN " + + $" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " + + $" DROP DATABASE [{DatabaseName}]; " + + $"END"; + cmd.ExecuteNonQuery(); + } + catch + { + // Swallow — the database name carries a random guid suffix so a + // stranded test database does not collide with future runs. + } + } + + /// + /// Throws an when invoked on an + /// unavailable fixture; tests should branch on + /// before reaching this code path. + /// + private void ThrowIfUnavailable() + { + if (!Available) + { + throw new InvalidOperationException( + $"MsSqlMigrationFixture is not Available: {SkipReason}"); + } + } + + private static string BuildPerDbConnectionString(string adminConnectionString, string databaseName) + { + var builder = new SqlConnectionStringBuilder(adminConnectionString) + { + InitialCatalog = databaseName, + }; + return builder.ConnectionString; + } + + private static string RedactPassword(string connectionString) + { + try + { + var builder = new SqlConnectionStringBuilder(connectionString) + { + Password = "***", + }; + return builder.ConnectionString; + } + catch + { + return ""; + } + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj b/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj index cbc2ae4..15923fd 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj @@ -10,7 +10,16 @@ + + +