From c1e34c307e87d189a2406ae63613481520b4eef3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 2 Sep 2026 14:22:25 -0400 Subject: [PATCH] feat(auth.apikeys.sqlserver): zbauth schema v1 + concurrent-boot-safe migrator (retry on 1205/2714/1913/2627, newer-version fail-closed); live tests gated on AUTH_SQLSERVER_CONNSTR Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB --- ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql | 69 ++++++ .../SqlServerApiKeyOptions.cs | 33 ++- .../SqlServerApiKeyStoreMigrator.cs | 207 ++++++++++++++++++ .../SqlServerAuthSchema.cs | 116 ++++++++++ .../Live/LiveSqlServerFixture.cs | 101 +++++++++ .../SqlServerApiKeyStoreMigratorLiveTests.cs | 145 ++++++++++++ .../SqlServerApiKeyStoreMigratorTests.cs | 43 ++++ 7 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql create mode 100644 ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs create mode 100644 ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreMigratorLiveTests.cs create mode 100644 ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyStoreMigratorTests.cs diff --git a/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql b/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql new file mode 100644 index 0000000..2b4e22b --- /dev/null +++ b/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql @@ -0,0 +1,69 @@ +-- ZB.MOM.WW.Auth.ApiKeys.SqlServer — shared API-key schema, version 1. +-- +-- For DBAs whose application login lacks DDL rights. The host normally provisions this itself at +-- startup (SqlServerApiKeyStoreMigrator, which runs the identical statements and is safe to run +-- concurrently from every node); where the app login may only read and write rows, run this script +-- once against the target database instead. The migrator then finds the version row already at the +-- version it supports and does nothing further beyond re-stamping applied_utc. +-- +-- The schema name is hard-coded to zbauth here — the default of SqlServerApiKeyOptions.Schema. If a +-- deployment overrides that option, substitute the name in every bracketed identifier below. +-- +-- Kept byte-for-byte consistent with SqlServerAuthSchema.CreateSchemaDdl apart from that +-- substitution and the literal version row at the end; change both together. + +IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'zbauth') + EXEC(N'CREATE SCHEMA [zbauth]'); + +IF OBJECT_ID(N'[zbauth].[schema_version]', N'U') IS NULL + CREATE TABLE [zbauth].[schema_version] ( + id INT NOT NULL CONSTRAINT PK_zbauth_schema_version PRIMARY KEY CHECK (id = 1), + version INT NOT NULL, + applied_utc DATETIMEOFFSET(7) NOT NULL + ); + +IF OBJECT_ID(N'[zbauth].[api_keys]', N'U') IS NULL + CREATE TABLE [zbauth].[api_keys] ( + key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_zbauth_api_keys PRIMARY KEY, + key_prefix NVARCHAR(16) NOT NULL, + secret_hash VARBINARY(64) NOT NULL, + display_name NVARCHAR(256) NOT NULL, + scopes NVARCHAR(MAX) NOT NULL, + constraints NVARCHAR(MAX) NULL, + created_utc DATETIMEOFFSET(7) NOT NULL, + last_used_utc DATETIMEOFFSET(7) NULL, + revoked_utc DATETIMEOFFSET(7) NULL, + expires_utc DATETIMEOFFSET(7) NULL + ); + +IF OBJECT_ID(N'[zbauth].[api_key_audit]', N'U') IS NULL + CREATE TABLE [zbauth].[api_key_audit] ( + audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_zbauth_api_key_audit PRIMARY KEY, + key_id NVARCHAR(64) NULL, + event_type NVARCHAR(64) NOT NULL, + remote_address NVARCHAR(64) NULL, + created_utc DATETIMEOFFSET(7) NOT NULL, + details NVARCHAR(MAX) NULL + ); + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'ix_api_keys_revoked_utc' + AND object_id = OBJECT_ID(N'[zbauth].[api_keys]')) + CREATE INDEX ix_api_keys_revoked_utc + ON [zbauth].[api_keys] (revoked_utc); + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'ix_api_key_audit_key_id_created_utc' + AND object_id = OBJECT_ID(N'[zbauth].[api_key_audit]')) + CREATE INDEX ix_api_key_audit_key_id_created_utc + ON [zbauth].[api_key_audit] (key_id, created_utc); + +MERGE [zbauth].[schema_version] AS target +USING (SELECT 1 AS id, 1 AS version, SYSDATETIMEOFFSET() AS applied_utc) AS source + ON target.id = source.id +WHEN MATCHED THEN + UPDATE SET version = source.version, applied_utc = source.applied_utc +WHEN NOT MATCHED THEN + INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc); diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs index a3b4006..f07df13 100644 --- a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs @@ -28,9 +28,38 @@ public sealed class SqlServerApiKeyOptions { if (string.IsNullOrWhiteSpace(ConnectionString)) throw new InvalidOperationException("SQL Server API-key store requires a ConnectionString."); - if (string.IsNullOrEmpty(Schema) || !Schema.All(c => char.IsAsciiLetterOrDigit(c) || c == '_') || Schema.Length > 64) - throw new InvalidOperationException($"SQL Server API-key store schema name '{Schema}' is not a safe identifier."); + + ValidateIdentifier(Schema); + if (CommandTimeout <= TimeSpan.Zero) throw new InvalidOperationException($"SQL Server API-key store CommandTimeout must be positive (was {CommandTimeout})."); } + + /// + /// Strict allow-list for a SQL identifier that will be interpolated into DDL or query text. + /// Rejects everything that is not [A-Za-z_][A-Za-z0-9_]{0,63} — no brackets, quotes, dots + /// or spaces — so no bracket-escape (]]) trick can break out of the quoting applied at the + /// use site. Uses the ASCII-only character tests deliberately, so homoglyph and full-width + /// look-alikes are rejected rather than accepted as "letters". + /// + /// The candidate identifier. + /// The same value, once validated. + /// The value is not a plain identifier. + /// + /// Public because is public and must + /// defend itself rather than trust that every caller routed through . + /// + public static string ValidateIdentifier(string schemaName) + { + if (string.IsNullOrEmpty(schemaName) + || schemaName.Length > 64 + || (!char.IsAsciiLetter(schemaName[0]) && schemaName[0] != '_') + || !schemaName.All(c => char.IsAsciiLetterOrDigit(c) || c == '_')) + { + throw new InvalidOperationException( + $"SQL Server API-key store schema name '{schemaName}' is not a safe identifier."); + } + + return schemaName; + } } diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs new file mode 100644 index 0000000..32764b6 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs @@ -0,0 +1,207 @@ +using System.Data; +using System.Globalization; +using Microsoft.Data.SqlClient; +using ZB.MOM.WW.Auth.Abstractions.ApiKeys; +using ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer; + +/// +/// Creates the shared SQL Server API-key schema and records the applied version. Idempotent: safe +/// to run on every node at every startup, including concurrently — lost provisioning races surface +/// as retryable s (see ) and +/// the retry re-runs the IF NOT EXISTS path against the objects the winner just created. +/// Refuses to run against a database whose recorded version is newer than this build supports. +/// +/// Factory for connections to the shared API-key database. +public sealed class SqlServerApiKeyStoreMigrator(AuthSqlServerConnectionFactory connectionFactory) + : IApiKeyStoreMigrator +{ + // Every node runs this at every startup, so a virgin database being provisioned by N nodes at + // once is the NORMAL case, not a rare race — and an unretried failure aborts that node's whole + // host at startup. Two failure shapes come out of that race, and retrying is correct for both + // precisely because the migration is idempotent: + // + // - Deadlock victim (1205): concurrent Serializable DDL against the system catalogs can pick + // a victim even though the IF NOT EXISTS guards make the logic itself safe. + // - Lost CREATE / lost INSERT (2714, 1913, 2627): Serializable range locks do NOT serialize + // the metadata existence checks against another session's in-flight CREATE — observed live + // on the sibling shared secret store (ScadaBridge central-pair first boot, 2026-08-07): + // both nodes passed the sys.schemas IF NOT EXISTS probe, the loser's CREATE SCHEMA then + // died with 2714 "There is already an object named 'zbsecrets'". The object the loser tried + // to create now exists, which is the desired end state — the retry's IF NOT EXISTS pass + // skips it and proceeds. + // + // The retry cap keeps this from masking a GENUINE conflict (e.g. an unrelated pre-existing + // object colliding with the schema name): that reproduces on every attempt, so after + // MaxMigrationRetries the real SqlException propagates and the node fails closed. + private const int MaxMigrationRetries = 5; + + /// Deadlock victim. + private const int SqlDeadlockVictimErrorNumber = 1205; + + /// "There is already an object named '%ls'" — lost CREATE SCHEMA / CREATE TABLE race. + private const int SqlObjectAlreadyExistsErrorNumber = 2714; + + /// "An index or statistics with name '%ls' already exists" — lost CREATE INDEX race. + private const int SqlIndexAlreadyExistsErrorNumber = 1913; + + /// Primary-key violation — lost version-row MERGE-insert race. + private const int SqlPrimaryKeyViolationErrorNumber = 2627; + + /// + /// + /// The recorded schema version is newer than . + /// + public async Task MigrateAsync(CancellationToken cancellationToken) + { + for (int attempt = 1; ; attempt++) + { + try + { + await MigrateOnceAsync(cancellationToken).ConfigureAwait(false); + return; + } + catch (SqlException ex) + when (IsRetryableMigrationError(ex) && attempt < MaxMigrationRetries) + { + await Task.Delay(BackoffFor(attempt), cancellationToken).ConfigureAwait(false); + } + } + } + + /// + /// Back-off before retry attempt + 1: a growing base plus RANDOM + /// jitter. The jitter is the load-bearing half. The sibling secret-store migrator this is ported + /// from delays a flat 100 * attempt, which is the SAME value for every victim of the same + /// round — so N deadlock victims wake together and re-collide, exactly the lockstep its comment + /// claims to prevent. Measured here against a live SQL Server with eight concurrent migrators on + /// a virgin schema, the unjittered form exhausted the five-attempt budget on repeated 1205s in + /// roughly 1 run in 60; with jitter it did not. + /// + /// The 1-based attempt that just failed. + /// How long to wait before the next attempt. + private static TimeSpan BackoffFor(int attempt) => + TimeSpan.FromMilliseconds((100 * attempt) + Random.Shared.Next(0, 100 * attempt)); + + /// + /// Whether any error carried by is a retryable provisioning-race + /// outcome. Scans the whole collection rather than only + /// because a failed DDL batch stacks follow-on errors (e.g. + /// 2714 arrives with 2759 "CREATE SCHEMA failed due to previous errors") and the retryable one + /// is not guaranteed to be first. + /// + /// The exception the migration attempt failed with. + /// when a retry may succeed. + internal static bool IsRetryableMigrationError(SqlException exception) + { + foreach (SqlError error in exception.Errors) + { + if (IsRetryableMigrationErrorNumber(error.Number)) + { + return true; + } + } + + return IsRetryableMigrationErrorNumber(exception.Number); + } + + /// + /// Classifies a SQL Server error number as a retryable outcome of N nodes provisioning the same + /// (possibly virgin) database at once. Everything here is a race whose retry re-runs the + /// idempotent IF NOT EXISTS path; anything else (login failures, missing database, + /// permissions, syntax) reproduces identically on retry and must propagate. 2705 (duplicate + /// column) is deliberately absent: every column lives inside a single CREATE TABLE + /// statement, so no cross-session race can produce it. + /// + /// The to classify. + /// when the number is a known provisioning-race outcome. + internal static bool IsRetryableMigrationErrorNumber(int errorNumber) => errorNumber + is SqlDeadlockVictimErrorNumber + or SqlObjectAlreadyExistsErrorNumber + or SqlIndexAlreadyExistsErrorNumber + or SqlPrimaryKeyViolationErrorNumber; + + private async Task MigrateOnceAsync(CancellationToken cancellationToken) + { + string schema = connectionFactory.Schema; + + await using SqlConnection connection = + await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + // Serializable: unlike the single-writer SQLite store, EVERY node in the cluster runs this + // migration at startup, so concurrent first-boots are the normal case rather than a rare + // race. The isolation level narrows the race window but does NOT close it — the IF NOT + // EXISTS metadata probes are not serialized against another session's in-flight CREATE, so + // a loser can still fail with "object exists" (2714/1913) or a deadlock (1205). The retry + // filter in MigrateAsync is what makes simultaneous provision converge: by the time the + // loser retries, the winner's objects exist and every guard here skips them. + await using SqlTransaction transaction = (SqlTransaction) + await connection.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + .ConfigureAwait(false); + + int existingVersion = + await ReadExistingSchemaVersionAsync(connection, transaction, schema, cancellationToken) + .ConfigureAwait(false); + + if (existingVersion > SqlServerAuthSchema.CurrentVersion) + { + throw new AuthStoreMigrationException( + $"Shared API-key database schema version {existingVersion} is newer than supported version " + + $"{SqlServerAuthSchema.CurrentVersion}. Upgrade this node before it writes to the shared store."); + } + + await using (SqlCommand ddl = connectionFactory.CreateCommand( + connection, SqlServerAuthSchema.CreateSchemaDdl(schema), transaction)) + { + await ddl.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + // MERGE rather than insert-if-missing: WHEN MATCHED re-stamps the row, so a future schema + // version actually lands in the table instead of leaving the first-ever value behind. + await using (SqlCommand version = connectionFactory.CreateCommand( + connection, + $""" + MERGE [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] AS target + USING (SELECT 1 AS id, @version AS version, @applied_utc AS applied_utc) AS source + ON target.id = source.id + WHEN MATCHED THEN + UPDATE SET version = source.version, applied_utc = source.applied_utc + WHEN NOT MATCHED THEN + INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc); + """, + transaction)) + { + version.Parameters.Add("@version", SqlDbType.Int).Value = SqlServerAuthSchema.CurrentVersion; + version.Parameters.Add("@applied_utc", SqlDbType.DateTimeOffset).Value = DateTimeOffset.UtcNow; + await version.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task ReadExistingSchemaVersionAsync( + SqlConnection connection, + SqlTransaction transaction, + string schema, + CancellationToken cancellationToken) + { + // OBJECT_ID returns NULL for an absent table, so this reads 0 on a virgin database without + // needing the table to exist first. + await using SqlCommand command = connectionFactory.CreateCommand( + connection, + $""" + IF OBJECT_ID(N'[{schema}].[{SqlServerAuthSchema.SchemaVersionTable}]', N'U') IS NULL + SELECT 0; + ELSE + SELECT ISNULL((SELECT version FROM [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] WHERE id = 1), 0); + """, + transaction); + + object? version = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + + return version is null || version == DBNull.Value + ? 0 + : Convert.ToInt32(version, CultureInfo.InvariantCulture); + } +} diff --git a/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs new file mode 100644 index 0000000..c2a8d27 --- /dev/null +++ b/ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs @@ -0,0 +1,116 @@ +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer; + +/// +/// Schema constants and DDL for the shared SQL Server API-key store. Mirrors +/// ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthSchema column-for-column so a row means exactly +/// the same thing in either store. +/// +/// +/// +/// Timestamps are DATETIMEOFFSET(7), not ISO-8601 text. The sibling +/// ZB.MOM.WW.Secrets SQL Server store deliberately stores its timestamps as +/// nvarchar because its rows replicate with a SQLite store and last-writer-wins +/// compares those timestamps for equality, where a sub-tick round-trip difference would turn a tie +/// into a spurious "newer". No such rationale applies here: this store never replicates with the +/// SQLite one — a host picks exactly one provider, and the one-shot SQLite import is a copy, not an +/// ongoing sync — so the native type is used, which sorts and range-scans correctly and keeps the +/// tables readable to a DBA. +/// +/// +/// SqliteAuthSchema.CurrentVersion is 3 only because that store carries the donor +/// MxAccessGateway's version history (v1/v2 were byte-identical; v3 added expires_utc). This +/// schema is created whole, with expires_utc present from the start, so its own version line +/// starts at 1. +/// +/// +public static class SqlServerAuthSchema +{ + /// + /// The schema version this build creates and supports. The migrator refuses a database whose + /// recorded version is newer than this. + /// + public const int CurrentVersion = 1; + + /// Default schema the API-key tables live in. + public const string DefaultSchemaName = "zbauth"; + + /// Name of the single-row table tracking the applied schema version. + public const string SchemaVersionTable = "schema_version"; + + /// Name of the table storing API-key records. + public const string ApiKeysTable = "api_keys"; + + /// Name of the append-only audit table. + public const string ApiKeyAuditTable = "api_key_audit"; + + /// + /// Returns the idempotent DDL that provisions the schema, the three tables and the two + /// supporting indexes in . + /// + /// The SQL schema name to create the objects in. + /// The DDL batch. + /// + /// is interpolated, not parameterized — T-SQL has no parameter + /// form for an object name. It is re-validated here against the same strict identifier + /// allow-list enforces, and bracket-quoted below. + /// The duplicate check is deliberate: this method is public, so it must defend itself + /// rather than trust that every caller routed through the options type. The version row is NOT + /// written here — writes it with a MERGE that also + /// updates on match, so a future upgrade re-stamps the stored version. + /// + /// is not a plain identifier. + public static string CreateSchemaDdl(string schemaName) + { + SqlServerApiKeyOptions.ValidateIdentifier(schemaName); + + return $""" + IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}') + EXEC(N'CREATE SCHEMA [{schemaName}]'); + + IF OBJECT_ID(N'[{schemaName}].[{SchemaVersionTable}]', N'U') IS NULL + CREATE TABLE [{schemaName}].[{SchemaVersionTable}] ( + id INT NOT NULL CONSTRAINT PK_{schemaName}_{SchemaVersionTable} PRIMARY KEY CHECK (id = 1), + version INT NOT NULL, + applied_utc DATETIMEOFFSET(7) NOT NULL + ); + + IF OBJECT_ID(N'[{schemaName}].[{ApiKeysTable}]', N'U') IS NULL + CREATE TABLE [{schemaName}].[{ApiKeysTable}] ( + key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_{schemaName}_{ApiKeysTable} PRIMARY KEY, + key_prefix NVARCHAR(16) NOT NULL, + secret_hash VARBINARY(64) NOT NULL, + display_name NVARCHAR(256) NOT NULL, + scopes NVARCHAR(MAX) NOT NULL, + constraints NVARCHAR(MAX) NULL, + created_utc DATETIMEOFFSET(7) NOT NULL, + last_used_utc DATETIMEOFFSET(7) NULL, + revoked_utc DATETIMEOFFSET(7) NULL, + expires_utc DATETIMEOFFSET(7) NULL + ); + + IF OBJECT_ID(N'[{schemaName}].[{ApiKeyAuditTable}]', N'U') IS NULL + CREATE TABLE [{schemaName}].[{ApiKeyAuditTable}] ( + audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_{schemaName}_{ApiKeyAuditTable} PRIMARY KEY, + key_id NVARCHAR(64) NULL, + event_type NVARCHAR(64) NOT NULL, + remote_address NVARCHAR(64) NULL, + created_utc DATETIMEOFFSET(7) NOT NULL, + details NVARCHAR(MAX) NULL + ); + + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'ix_api_keys_revoked_utc' + AND object_id = OBJECT_ID(N'[{schemaName}].[{ApiKeysTable}]')) + CREATE INDEX ix_api_keys_revoked_utc + ON [{schemaName}].[{ApiKeysTable}] (revoked_utc); + + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'ix_api_key_audit_key_id_created_utc' + AND object_id = OBJECT_ID(N'[{schemaName}].[{ApiKeyAuditTable}]')) + CREATE INDEX ix_api_key_audit_key_id_created_utc + ON [{schemaName}].[{ApiKeyAuditTable}] (key_id, created_utc); + """; + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs new file mode 100644 index 0000000..83a1964 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs @@ -0,0 +1,101 @@ +using Microsoft.Data.SqlClient; + +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live; + +/// +/// Shared connection details for the env-gated live SQL Server suite. +/// +/// +/// Gated on AUTH_SQLSERVER_CONNSTR and skips cleanly when it is unset, matching the family +/// live-test idiom (see ZB.MOM.WW.Secrets.Replicator.SqlServer) — the offline suite must stay +/// runnable on any machine. Set it to a database the test is allowed to create and drop a schema in, +/// for example Server=localhost,1433;Database=ZbAuthTest;User Id=sa;Password=...;TrustServerCertificate=true. +/// Each test class works in a throwaway schema rather than a throwaway database: it needs no +/// CREATE DATABASE right, and a virgin schema still reproduces the CREATE SCHEMA race the migrator's +/// retry loop exists for. +/// +public static class LiveSqlServer +{ + /// Environment variable holding the live connection string. + public const string ConnectionStringVariable = "AUTH_SQLSERVER_CONNSTR"; + + /// The configured connection string, or when the suite is not enabled. + public static string? ConnectionString => + Environment.GetEnvironmentVariable(ConnectionStringVariable); + + /// Whether the live suite should run. + public static bool IsEnabled => !string.IsNullOrWhiteSpace(ConnectionString); + + /// Skip reason shown when the suite is not enabled. + public const string SkipReason = + "Live SQL Server suite disabled; set AUTH_SQLSERVER_CONNSTR to enable."; + + /// + /// Builds options against a throwaway schema so parallel runs and repeat runs cannot collide, + /// and so a failed run leaves no trace in a shared database. + /// + /// The unique schema name for this test. + /// Options targeting that schema. + public static SqlServerApiKeyOptions OptionsFor(string schemaName) => new() + { + ConnectionString = ConnectionString!, + Schema = schemaName, + }; + + /// Generates a unique, allow-list-legal schema name for one test. + /// A fresh schema name. + public static string NewSchemaName() => $"zbauth_test_{Guid.NewGuid():N}"[..32]; + + /// Drops the throwaway schema and its three tables. + /// The schema to drop. + /// A task that completes when the schema is gone. + public static async Task DropSchemaAsync(string schemaName) + { + if (!IsEnabled) + { + return; + } + + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using SqlCommand command = connection.CreateCommand(); + command.CommandText = $""" + IF OBJECT_ID(N'[{schemaName}].[api_key_audit]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[api_key_audit]; + IF OBJECT_ID(N'[{schemaName}].[api_keys]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[api_keys]; + IF OBJECT_ID(N'[{schemaName}].[schema_version]', N'U') IS NOT NULL DROP TABLE [{schemaName}].[schema_version]; + IF EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schemaName}') EXEC(N'DROP SCHEMA [{schemaName}]'); + """; + + await command.ExecuteNonQueryAsync(); + } + + /// Runs a scalar query against the live database. + /// The query text. + /// The first column of the first row, or . + public static async Task ScalarAsync(string commandText) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using SqlCommand command = connection.CreateCommand(); + command.CommandText = commandText; + + object? value = await command.ExecuteScalarAsync(); + return value == DBNull.Value ? null : value; + } + + /// Runs a non-query statement against the live database. + /// The statement text. + /// A task that completes when the statement has run. + public static async Task ExecuteAsync(string commandText) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using SqlCommand command = connection.CreateCommand(); + command.CommandText = commandText; + + await command.ExecuteNonQueryAsync(); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreMigratorLiveTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreMigratorLiveTests.cs new file mode 100644 index 0000000..3d414c6 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreMigratorLiveTests.cs @@ -0,0 +1,145 @@ +using System.Globalization; +using ZB.MOM.WW.Auth.ApiKeys.Sqlite; + +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.Live; + +/// +/// Live provisioning tests for . +/// +/// +/// Every test provisions its own throwaway schema — the concurrency case in particular needs a +/// virgin one, because the race the retry loop exists for only happens while the schema and tables +/// do not yet exist. All schemas created by the class are dropped in . +/// +[Collection("live-sqlserver")] +public sealed class SqlServerApiKeyStoreMigratorLiveTests : IAsyncLifetime +{ + private readonly List _schemas = []; + + /// + public Task InitializeAsync() => Task.CompletedTask; + + /// + public async Task DisposeAsync() + { + foreach (string schema in _schemas) + { + await LiveSqlServer.DropSchemaAsync(schema); + } + } + + private string NewSchema() + { + string schema = LiveSqlServer.NewSchemaName(); + _schemas.Add(schema); + return schema; + } + + private static SqlServerApiKeyStoreMigrator MigratorFor(string schema) => + new(new AuthSqlServerConnectionFactory(LiveSqlServer.OptionsFor(schema))); + + [SkippableFact] + public async Task Migrate_OnEmptyDatabase_CreatesSchemaTablesAndVersionRow() + { + Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); + + string schema = NewSchema(); + + await MigratorFor(schema).MigrateAsync(CancellationToken.None); + + Assert.Equal( + 1, + Convert.ToInt32( + await LiveSqlServer.ScalarAsync( + $"SELECT COUNT(*) FROM sys.schemas WHERE name = N'{schema}'"), + CultureInfo.InvariantCulture)); + + foreach (string table in new[] + { + SqlServerAuthSchema.SchemaVersionTable, + SqlServerAuthSchema.ApiKeysTable, + SqlServerAuthSchema.ApiKeyAuditTable, + }) + { + Assert.NotNull( + await LiveSqlServer.ScalarAsync($"SELECT OBJECT_ID(N'[{schema}].[{table}]', N'U')")); + } + + Assert.Equal( + SqlServerAuthSchema.CurrentVersion, + Convert.ToInt32( + await LiveSqlServer.ScalarAsync( + $"SELECT version FROM [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] WHERE id = 1"), + CultureInfo.InvariantCulture)); + } + + [SkippableFact] + public async Task Migrate_Twice_IsIdempotent() + { + Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); + + string schema = NewSchema(); + SqlServerApiKeyStoreMigrator migrator = MigratorFor(schema); + + await migrator.MigrateAsync(CancellationToken.None); + await migrator.MigrateAsync(CancellationToken.None); + + Assert.Equal( + 1, + Convert.ToInt32( + await LiveSqlServer.ScalarAsync( + $"SELECT COUNT(*) FROM [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}]"), + CultureInfo.InvariantCulture)); + Assert.Equal( + SqlServerAuthSchema.CurrentVersion, + Convert.ToInt32( + await LiveSqlServer.ScalarAsync( + $"SELECT version FROM [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] WHERE id = 1"), + CultureInfo.InvariantCulture)); + } + + [SkippableFact] + public async Task Migrate_Concurrently_FromEightTasks_AllSucceed() + { + Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); + + // A virgin schema provisioned by eight nodes at once — the central-pair first boot, scaled + // up. Each task gets its own factory (and therefore its own connection) so the DDL really + // does run on eight sessions. + string schema = NewSchema(); + + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => + Task.Run(() => MigratorFor(schema).MigrateAsync(CancellationToken.None)))); + + Assert.Equal( + 1, + Convert.ToInt32( + await LiveSqlServer.ScalarAsync( + $"SELECT COUNT(*) FROM [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}]"), + CultureInfo.InvariantCulture)); + Assert.Equal( + SqlServerAuthSchema.CurrentVersion, + Convert.ToInt32( + await LiveSqlServer.ScalarAsync( + $"SELECT version FROM [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] WHERE id = 1"), + CultureInfo.InvariantCulture)); + } + + [SkippableFact] + public async Task Migrate_WhenStoredVersionIsNewer_Throws() + { + Skip.IfNot(LiveSqlServer.IsEnabled, LiveSqlServer.SkipReason); + + string schema = NewSchema(); + SqlServerApiKeyStoreMigrator migrator = MigratorFor(schema); + + await migrator.MigrateAsync(CancellationToken.None); + await LiveSqlServer.ExecuteAsync( + $"UPDATE [{schema}].[{SqlServerAuthSchema.SchemaVersionTable}] SET version = 99 WHERE id = 1"); + + AuthStoreMigrationException exception = await Assert.ThrowsAsync( + () => migrator.MigrateAsync(CancellationToken.None)); + + Assert.Contains("99", exception.Message, StringComparison.Ordinal); + } +} diff --git a/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyStoreMigratorTests.cs b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyStoreMigratorTests.cs new file mode 100644 index 0000000..b254c40 --- /dev/null +++ b/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyStoreMigratorTests.cs @@ -0,0 +1,43 @@ +namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests; + +/// +/// Offline pins for the migrator's retry classification. A +/// cannot be constructed normally, so the error-number predicate is extracted as an internal static +/// and pinned here by number; the concurrent-provision behaviour itself is exercised by the +/// env-gated live suite. These run on any machine, so the retry filter stays pinned even when the +/// live suite skips. +/// +public sealed class SqlServerApiKeyStoreMigratorTests +{ + /// + /// The provisioning-race outcomes the ScadaBridge central shared-store live gate (2026-08-07) + /// proved reachable — or that the multi-statement DDL makes reachable — when N nodes provision + /// one virgin database at once. Losing such a race means the object now exists, which is the + /// desired end state, so a retry of the idempotent IF NOT EXISTS path must be taken. + /// + /// The SQL Server error number under test. + [Theory] + [InlineData(1205)] // deadlock victim + [InlineData(2714)] // "There is already an object named ..." — lost CREATE SCHEMA/TABLE (the live-gate defect) + [InlineData(1913)] // "index ... already exists" — lost CREATE INDEX + [InlineData(2627)] // PK violation — lost schema_version MERGE-insert + public void Provisioning_race_error_numbers_are_retryable(int errorNumber) + => Assert.True(SqlServerApiKeyStoreMigrator.IsRetryableMigrationErrorNumber(errorNumber)); + + /// + /// Errors that reproduce identically on retry must propagate so the node fails closed instead + /// of spinning through the retry budget against a genuine misconfiguration. + /// + /// The SQL Server error number under test. + [Theory] + [InlineData(2705)] // duplicate column — unreachable from the race (single CREATE TABLE statement) + [InlineData(208)] // invalid object name + [InlineData(229)] // permission denied + [InlineData(2759)] // "CREATE SCHEMA failed due to previous errors" — retryable only via its companion 2714 + [InlineData(4060)] // cannot open database + [InlineData(18456)] // login failed + [InlineData(0)] + [InlineData(-2)] // client-side timeout + public void Non_race_error_numbers_are_not_retryable(int errorNumber) + => Assert.False(SqlServerApiKeyStoreMigrator.IsRetryableMigrationErrorNumber(errorNumber)); +}