using System.Text.Json; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; /// /// Proves the composition layer: (config blob → live driver) and /// (a connectionStringRef name → the secret it stands for). /// Three things here are behaviour rather than plumbing, and each has its own test below. /// (1) The string-enum guard — the driver-page enum-serialization defect that bit the AdminUI shipped /// configs whose enum fields were written as ORDINALS while the consuming DTO was string-typed, faulting the /// driver at deploy time. The factory's options therefore both accept a numeric provider and write the /// NAME. (2) Config validationoperationTimeout > commandTimeout is deliberately /// unenforced in the reader and the shell, so the factory is the only place it can be caught before a /// deployment silently masks its own server-side backstop. (3) Credential hygiene — the resolved /// connection string is a secret; it must not reach a log line or an exception message, and the tests below /// assert that rather than trusting the reviewer. /// public sealed class SqlDriverFactoryTests { // ---- the plan's headline legs ---- [Fact] public void CreateInstance_missingConnectionStringRef_throwsActionable() => Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance("d1", """{"provider":"SqlServer"}""", null)) .Message.ShouldContain("connectionStringRef"); [Fact] public void ConnectionStringRef_resolvesFromEnv() { var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=x;Database=y;"); SqlConnectionStringResolver.Resolve(name).ShouldBe("Server=x;Database=y;"); } [Fact] public void Provider_serializesAsNameNotNumber() { var json = JsonSerializer.Serialize( new SqlDriverConfigDto { Provider = SqlProvider.SqlServer }, SqlDriverFactoryExtensions.JsonOptionsForTest); json.ShouldContain("\"SqlServer\""); json.ShouldNotContain("\"provider\":0"); } // ---- the other half of the enum guard: a NUMERIC provider must still parse ---- [Fact] public void CreateInstance_providerWrittenAsANumber_stillParses() { // The defect's real shape: an authoring surface serialises the enum as an ordinal. Rejecting that // would fault the driver at deploy time, which is exactly what the guard exists to prevent — so the // options accept both spellings on the way IN, and only ever emit the name on the way OUT. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); var driver = SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"provider":0,"connectionStringRef":"{{name}}"}""", null); driver.DriverType.ShouldBe(SqlDriver.DriverTypeName); } [Fact] public void CreateInstance_unknownJsonMembers_areIgnoredRatherThanFatal() { // A config blob authored against a newer (or older) schema must not brick the driver. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); var driver = SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"connectionStringRef":"{{name}}","somethingNobodyHasShippedYet":true}""", null); driver.DriverInstanceId.ShouldBe("d1"); } // ---- connection-string resolution ---- [Fact] public void Resolve_whenTheEnvironmentVariableIsAbsent_throwsNamingTheVariable() { var name = NewRef(); var ex = Should.Throw(() => SqlConnectionStringResolver.Resolve(name)); // The operator's next action is "set this variable", so the message must name it exactly. ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name)); } [Fact] public void Resolve_whenTheEnvironmentVariableIsBlank_isTreatedAsAbsent() { // An empty value is a half-provisioned deployment, not a connection string — failing here beats // handing "" to the provider and reporting an unintelligible ADO.NET error. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), " "); Should.Throw(() => SqlConnectionStringResolver.Resolve(name)); } [Fact] public void CreateInstance_whenTheRefResolvesToNothing_namesTheEnvironmentVariable_notTheRef() { var name = NewRef(); var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"connectionStringRef":"{{name}}"}""", null)); ex.Message.ShouldContain(SqlConnectionStringResolver.EnvironmentVariableFor(name)); } // ---- provider gate ---- [Fact] public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo() { var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db"); var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null)); ex.Message.ShouldContain("Postgres"); ex.Message.ShouldContain("not available in this build"); } // ---- config validation (the reader and the shell deliberately do NOT do this) ---- [Fact] public void CreateInstance_operationTimeoutNotGreaterThanCommandTimeout_isRejected() { // Inverted, the client-side wall-clock abort always fires first and the server-side CommandTimeout // backstop can never be reached — the deployment silently loses one of its two independent bounds. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( "d1", $$""" {"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"} """, null)); ex.Message.ShouldContain("operationTimeout"); ex.Message.ShouldContain("commandTimeout"); } [Fact] public void CreateInstance_equalTimeouts_areAlsoRejected() { // "Strictly greater" (design §8.3): equal leaves the two bounds racing, which is the same defect // with a coin toss on top. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( "d1", $$""" {"connectionStringRef":"{{name}}","commandTimeout":"00:00:10","operationTimeout":"00:00:10"} """, null)); } [Theory] [InlineData("\"commandTimeout\":\"00:00:00\"")] [InlineData("\"operationTimeout\":\"-00:00:05\"")] [InlineData("\"defaultPollInterval\":\"00:00:00\"")] [InlineData("\"maxConcurrentGroups\":0")] public void CreateInstance_nonPositiveKnobs_areRejected(string knobJson) { var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"connectionStringRef":"{{name}}",{{knobJson}}}""", null)); } [Fact] public void CreateInstance_theDocumentedDefaults_passValidation() { // The falsifiability control for the four rejection legs above: an all-defaults config must build. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); var options = SqlDriverFactoryExtensions.BuildOptions( new SqlDriverConfigDto { ConnectionStringRef = name }, "d1"); options.CommandTimeout.ShouldBe(TimeSpan.FromSeconds(10)); options.OperationTimeout.ShouldBe(TimeSpan.FromSeconds(15)); options.DefaultPollInterval.ShouldBe(TimeSpan.FromSeconds(5)); options.MaxConcurrentGroups.ShouldBe(4); options.NullIsBad.ShouldBeFalse(); options.OperationTimeout.ShouldBeGreaterThan(options.CommandTimeout); } // ---- rawTags: how the deploy artifact actually delivers a driver's tags ---- [Fact] public void BuildOptions_carriesTheArtifactsRawTagsOntoTheOptions() { var dto = JsonSerializer.Deserialize( """ { "connectionStringRef":"anything", "rawTags":[ {"rawPath":"Plant/Sql/db1/Speed","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false}, {"rawPath":"Plant/Sql/db1/Temp","tagConfig":"{\"driver\":\"Sql\"}","writeIdempotent":false} ] } """, SqlDriverFactoryExtensions.JsonOptionsForTest)!; var options = SqlDriverFactoryExtensions.BuildOptions(dto, "d1"); options.RawTags.Count.ShouldBe(2); options.RawTags[0].RawPath.ShouldBe("Plant/Sql/db1/Speed"); options.RawTags[1].RawPath.ShouldBe("Plant/Sql/db1/Temp"); } [Fact] public void BuildOptions_withNoRawTags_yieldsAnEmptyList_notNull() { var options = SqlDriverFactoryExtensions.BuildOptions( new SqlDriverConfigDto { ConnectionStringRef = "anything" }, "d1"); options.RawTags.ShouldBeEmpty(); } // ---- v1 is read-only, structurally ---- [Fact] public void CreateInstance_anAuthoredAllowWrites_warnsLoudly_andTheDriverStaysReadOnly() { // allowWrites is inert by construction (IWritable is not implemented), so honouring it is impossible // and rejecting it would brick a deployment over a flag that cannot do harm. The remaining failure // mode is an operator who believes writes are enabled — which only a loud warning closes. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); var loggerFactory = new RecordingLoggerFactory(); var driver = SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory); driver.ShouldNotBeAssignableTo(); var warning = loggerFactory.Entries .Where(e => e.Level >= LogLevel.Warning) .ShouldHaveSingleItem(); warning.Message.ShouldContain("allowWrites"); warning.Message.ShouldContain("read-only"); } [Fact] public void CreateInstance_withoutAllowWrites_logsNoWarning() { // The falsifiability control for the warning above — a warning that always fires proves nothing. var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); var loggerFactory = new RecordingLoggerFactory(); SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":false}""", loggerFactory); loggerFactory.Entries.Where(e => e.Level >= LogLevel.Warning).ShouldBeEmpty(); // ...and the factory DID log — so "no warning" is a statement about severity, not about silence. loggerFactory.Entries.ShouldNotBeEmpty(); } // ---- credential hygiene ---- [Fact] public void CreateInstance_neverPutsTheResolvedConnectionStringIntoALogOrTheEndpoint() { const string secret = "SuperSecret-PleaseDoNotLogMe"; var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), $"Server=srv;Database=db;User ID=sa;Password={secret}"); var loggerFactory = new RecordingLoggerFactory(); var driver = (SqlDriver)SqlDriverFactoryExtensions.CreateInstance( "d1", $$"""{"connectionStringRef":"{{name}}","allowWrites":true}""", loggerFactory); // The credential-free rendering IS emitted — the factory logs where the driver points, which is what // makes "no connection string" a real constraint rather than "log nothing and call it safe". driver.Endpoint.ShouldBe("srv/db"); foreach (var entry in loggerFactory.Entries) entry.Message.ShouldNotContain(secret); loggerFactory.Entries.ShouldNotBeEmpty(); // ...and there WAS something to leak into. } [Fact] public void CreateInstance_whenValidationFails_theExceptionCarriesNoConnectionString() { const string secret = "SuperSecret-PleaseDoNotThrowMe"; var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), $"Server=srv;Database=db;Password={secret}"); // Validation deliberately runs AFTER resolution here, so the throwing path is one that HAS the // secret in hand — the case where a careless $"...{connectionString}" would actually leak. var ex = Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance( "d1", $$""" {"connectionStringRef":"{{name}}","commandTimeout":"00:00:30","operationTimeout":"00:00:15"} """, null)); ex.ToString().ShouldNotContain(secret); } // ---- registry wiring ---- [Fact] public void Register_registersTheFactoryUnderTheDriversTypeName() { var name = NewRef(); using var _ = new EnvironmentVariableScope( SqlConnectionStringResolver.EnvironmentVariableFor(name), "Server=srv;Database=db"); var registry = new DriverFactoryRegistry(); SqlDriverFactoryExtensions.Register(registry); var factory = registry.TryGet(SqlDriverFactoryExtensions.DriverTypeName).ShouldNotBeNull(); var driver = factory("d1", $$"""{"connectionStringRef":"{{name}}"}"""); driver.ShouldBeOfType(); driver.DriverType.ShouldBe("Sql"); } [Fact] public void DriverTypeName_matchesTheDriversOwnConstant() // Task 11 unifies both onto DriverTypeNames.Sql; until then they must not drift apart. => SqlDriverFactoryExtensions.DriverTypeName.ShouldBe(SqlDriver.DriverTypeName); // ---- argument guards ---- [Theory] [InlineData("")] [InlineData(" ")] public void CreateInstance_blankInputs_areRejected(string blank) { Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance(blank, """{"connectionStringRef":"x"}""", null)); Should.Throw(() => SqlDriverFactoryExtensions.CreateInstance("d1", blank, null)); } [Fact] public void CreateInstance_configThatIsNotJson_throwsNamingTheDriverInstance() { var ex = Should.Throw( () => SqlDriverFactoryExtensions.CreateInstance("d1", "not json at all", null)); ex.Message.ShouldContain("d1"); } /// A per-test connection-string ref, so no two tests can collide over one process-wide env var. private static string NewRef() => $"OtOpcUaSqlTest{Guid.NewGuid():N}"; } /// /// Sets one environment variable for the life of the scope and restores its previous value — including /// restoring it to . Environment variables are process-global, so a test that sets /// one without restoring it leaks into every test that runs after it in the same process. /// internal sealed class EnvironmentVariableScope : IDisposable { private readonly string _name; private readonly string? _previous; /// Sets to , remembering what was there. /// The environment variable to set. /// The value to set it to. public EnvironmentVariableScope(string name, string? value) { _name = name; _previous = Environment.GetEnvironmentVariable(name); Environment.SetEnvironmentVariable(name, value); } /// Restores the previous value. public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); } /// /// Captures what the factory logged, so "warned" and "did not leak the connection string" are asserted /// rather than assumed. /// internal sealed class RecordingLoggerFactory : ILoggerFactory { private readonly List _entries = []; /// The log entries recorded so far, in order. public IReadOnlyList Entries { get { lock (_entries) return [.. _entries]; } } /// public ILogger CreateLogger(string categoryName) => new Recorder(this); /// public void AddProvider(ILoggerProvider provider) { } /// public void Dispose() { } private void Record(LogLevel level, string message) { lock (_entries) _entries.Add(new LogEntry(level, message)); } /// One captured log entry. /// The level it was logged at. /// The formatted message. internal sealed record LogEntry(LogLevel Level, string Message); private sealed class Recorder(RecordingLoggerFactory owner) : ILogger { public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => owner.Record(logLevel, formatter(state, exception)); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } }