feat(configdb): add AuditLog migration with monthly partitioning and DB roles (#23)
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).
This commit is contained in:
1553
src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
generated
Normal file
1553
src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>pf_AuditLog_Month</c> (RANGE RIGHT) with 24
|
||||||
|
/// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC.
|
||||||
|
/// 2. Partition scheme <c>ps_AuditLog_Month</c> mapping every partition to
|
||||||
|
/// [PRIMARY] (dev/test parity; production may relocate via filegroups).
|
||||||
|
/// 3. <c>AuditLog</c> 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
|
||||||
|
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c>.
|
||||||
|
/// 5. Two database roles:
|
||||||
|
/// - <c>scadalink_audit_writer</c>: 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.
|
||||||
|
/// - <c>scadalink_audit_purger</c>: 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.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddAuditLogTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,123 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
b.ToTable("DataProtectionKeys");
|
b.ToTable("DataProtectionKeys");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("EventId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OccurredAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Actor")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Channel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CorrelationId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("DurationMs")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorDetail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("Extra")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ForwardState")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<int?>("HttpStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("IngestedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<bool>("PayloadTruncated")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("RequestSummary")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseSummary")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceInstanceId")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceScript")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceSiteId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("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 =>
|
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Tests early-return with a clear test-output message when the
|
||||||
|
/// <see cref="MsSqlMigrationFixture"/> reports unavailable, so CI without
|
||||||
|
/// the dev MSSQL container still runs the suite green.
|
||||||
|
/// </remarks>
|
||||||
|
public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<string?>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<int>(
|
||||||
|
"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<SqlException>(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 ------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> 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<T> ScalarAsync<T>(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))!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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), <see cref="Available"/>
|
||||||
|
/// is set to false so each test can early-return cleanly — keeping the test
|
||||||
|
/// suite green wherever it runs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// xUnit 2.9.x has no dynamic Skip; the early-return-after-output pattern is
|
||||||
|
/// the project convention. Tests calling <see cref="EnsureAvailableOrSkip"/>
|
||||||
|
/// receive a clear log line in the output explaining why they did not run.
|
||||||
|
/// </remarks>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool Available { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populated when <see cref="Available"/> is false; describes why the
|
||||||
|
/// fixture chose to skip (env var unset, connect failed, etc.).
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the EF migrations to the per-fixture test database via a freshly
|
||||||
|
/// constructed <see cref="ScadaLinkDbContext"/> pointed at it. Uses the
|
||||||
|
/// schema-only single-argument constructor — the AuditLog migration does
|
||||||
|
/// not write secret-bearing columns at apply time.
|
||||||
|
/// </summary>
|
||||||
|
public async Task ApplyAuditMigrationAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ThrowIfUnavailable();
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(ConnectionString)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var context = new ScadaLinkDbContext(options);
|
||||||
|
await context.Database.MigrateAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience for opening a fresh <see cref="SqlConnection"/> to the test
|
||||||
|
/// database. Caller is responsible for disposal.
|
||||||
|
/// </summary>
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throws an <see cref="InvalidOperationException"/> when invoked on an
|
||||||
|
/// unavailable fixture; tests should branch on <see cref="Available"/>
|
||||||
|
/// before reaching this code path.
|
||||||
|
/// </summary>
|
||||||
|
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 "<unparseable>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,16 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<!--
|
||||||
|
Bundle C migration integration tests need Microsoft.Data.SqlClient. EF
|
||||||
|
SqlServer 10.0.7 pulls in SqlClient >= 6.1.1, but the central package
|
||||||
|
version is pinned at 6.0.2 (the version the production
|
||||||
|
ExternalSystemGateway uses). Override the version locally for the test
|
||||||
|
project only; production assemblies are unaffected.
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
|||||||
Reference in New Issue
Block a user