diff --git a/src/Cli.Tests/AutoConfigSimulateTests.cs b/src/Cli.Tests/AutoConfigSimulateTests.cs
index ef90a587dd..38aba1b39e 100644
--- a/src/Cli.Tests/AutoConfigSimulateTests.cs
+++ b/src/Cli.Tests/AutoConfigSimulateTests.cs
@@ -24,14 +24,61 @@ public class AutoConfigSimulateTests
"Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;" +
"Password=@env('MSSQL_SA_PASSWORD');MultipleActiveResultSets=False;Connection Timeout=30;";
+ ///
+ /// A fully resolved connection string containing no @env()/@akv() references. It points at a port
+ /// nothing listens on with a short timeout, so the tests that reach the query stage fail fast
+ /// without requiring a database.
+ ///
+ private const string MSSQL_RESOLVED_CONNECTION_STRING =
+ "Server=tcp:127.0.0.1,1;Persist Security Info=False;User ID=sa;" +
+ "Password=placeholder;TrustServerCertificate=True;Connect Timeout=1;";
+
+ ///
+ /// Name of an environment variable that is deliberately never set, used to produce an
+ /// unresolved @env() reference in a connection string.
+ ///
+ private const string UNSET_ENV_VAR_NAME = "DAB_TEST_UNSET_CONNECTION_SECRET";
+
+ ///
+ /// The OpenTelemetry environment variables that `dab init` always references from the generated
+ /// config. They are normally unset, which is the scenario covered by issue #3791.
+ ///
+ private static readonly string[] _openTelemetryEnvVarNames = new[]
+ {
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
+ "OTEL_EXPORTER_OTLP_HEADERS",
+ "OTEL_SERVICE_NAME"
+ };
+
+ ///
+ /// Every environment variable these tests unset. The OpenTelemetry names are the real ones an
+ /// init-generated config references, so they may legitimately be set in the host environment.
+ ///
+ private static readonly string[] _mutatedEnvVarNames =
+ _openTelemetryEnvVarNames.Append(UNSET_ENV_VAR_NAME).ToArray();
+
private IFileSystem? _fileSystem;
private FileSystemRuntimeConfigLoader? _runtimeConfigLoader;
+ ///
+ /// Host values of , captured before each test clears them and
+ /// restored in cleanup. Without this, a cleared variable leaks into every test that runs later in
+ /// the same process, making unrelated tests fail depending on ordering and host environment.
+ ///
+ private readonly Dictionary _originalEnvVarValues = new();
+
[TestInitialize]
public void TestInitialize()
{
+ foreach (string name in _mutatedEnvVarNames)
+ {
+ _originalEnvVarValues[name] = Environment.GetEnvironmentVariable(name);
+ }
+
_fileSystem = FileSystemUtils.ProvisionMockFileSystem();
- _runtimeConfigLoader = new FileSystemRuntimeConfigLoader(_fileSystem);
+ // isCliLoader mirrors how the CLI builds its loader. Without it a successful load starts a
+ // hot-reload file watcher against the mock file system, whose retries add seconds per test.
+ _runtimeConfigLoader = new FileSystemRuntimeConfigLoader(_fileSystem, isCliLoader: true);
ILoggerFactory loggerFactory = TestLoggerSupport.ProvisionLoggerFactory();
ConfigGenerator.SetLoggerForCliConfigGenerator(loggerFactory.CreateLogger());
@@ -41,20 +88,130 @@ public void TestInitialize()
[TestCleanup]
public void TestCleanup()
{
+ foreach (KeyValuePair original in _originalEnvVarValues)
+ {
+ Environment.SetEnvironmentVariable(original.Key, original.Value);
+ }
+
+ _originalEnvVarValues.Clear();
_fileSystem = null;
_runtimeConfigLoader = null;
}
///
/// Tests that the simulate command fails when no autoentities are defined in the config.
+ /// The config is produced by `dab init`, which always writes unset OpenTelemetry @env()
+ /// placeholders, so asserting on the specific error also proves the command reached the
+ /// autoentities check rather than aborting during the config load.
///
[TestMethod]
public void TestSimulateAutoentities_NoAutoentitiesDefined()
{
// Arrange: create an MSSQL config without autoentities
- InitOptions initOptions = CreateBasicInitOptionsForMsSqlWithConfig(config: TEST_RUNTIME_CONFIG_FILE);
+ ClearOpenTelemetryEnvironmentVariables();
+ InitOptions initOptions = CreateInitOptionsForMsSql(MSSQL_RESOLVED_CONNECTION_STRING);
+ Assert.IsTrue(TryGenerateConfig(initOptions, _runtimeConfigLoader!, _fileSystem!));
+
+ Mock> loggerMock = new();
+ SetLoggerForCliConfigGenerator(loggerMock.Object);
+
+ AutoConfigSimulateOptions options = new(config: TEST_RUNTIME_CONFIG_FILE);
+
+ // Act
+ bool success = TrySimulateAutoentities(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert
+ Assert.IsFalse(success);
+ AssertErrorLogged(loggerMock, "No autoentities definitions found in the config file.");
+ }
+
+ ///
+ /// Regression test for https://github.com/Azure/data-api-builder/issues/3791.
+ /// A config generated by `dab init` references OpenTelemetry environment variables that are
+ /// normally unset. Those unresolved @env() references must not abort the config load, so the
+ /// command proceeds all the way to the database query stage.
+ ///
+ [TestMethod]
+ public void TestSimulateAutoentities_UnsetTelemetryEnvVars_DoesNotBlockConfigLoad()
+ {
+ // Arrange: an init-generated config (unset OpenTelemetry @env() placeholders) with an autoentity.
+ ClearOpenTelemetryEnvironmentVariables();
+ InitOptions initOptions = CreateInitOptionsForMsSql(MSSQL_RESOLVED_CONNECTION_STRING);
+ Assert.IsTrue(TryGenerateConfig(initOptions, _runtimeConfigLoader!, _fileSystem!));
+
+ AutoConfigOptions autoConfigOptions = new(
+ definitionName: "books-filter",
+ patternsInclude: new[] { "dbo.books" },
+ config: TEST_RUNTIME_CONFIG_FILE);
+ Assert.IsTrue(ConfigGenerator.TryConfigureAutoentities(autoConfigOptions, _runtimeConfigLoader!, _fileSystem!));
+
+ Mock> loggerMock = new();
+ SetLoggerForCliConfigGenerator(loggerMock.Object);
+
+ AutoConfigSimulateOptions options = new(config: TEST_RUNTIME_CONFIG_FILE);
+
+ // Act
+ bool success = TrySimulateAutoentities(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert: the run fails only because no database is listening, which means the config load,
+ // the database type check, the autoentities check and the connection string checks all passed.
+ Assert.IsFalse(success, "No database is listening, so the simulation cannot succeed.");
+ AssertErrorLogged(loggerMock, "Failed to query the database");
+ AssertErrorNotLogged(loggerMock, "Failed to read the config file");
+ AssertErrorNotLogged(loggerMock, "No autoentities definitions found");
+ }
+
+ ///
+ /// A user-provided config path is not checked for existence before the load (see
+ /// TryGetConfigFileBasedOnCliPrecedence), so a missing file reaches TryLoadConfig, which logs
+ /// "Unable to find config file". Draining the loader's buffer now delivers that error, so the
+ /// generic fallback must stay silent rather than reporting the same failure a second time.
+ ///
+ [TestMethod]
+ public void TestSimulateAutoentities_MissingConfigFile_DoesNotLogGenericError()
+ {
+ // Arrange: a config path that was never written to the mock file system.
+ const string MISSING_CONFIG_FILE = "dab-config.missing.json";
+ Assert.IsFalse(_fileSystem!.File.Exists(MISSING_CONFIG_FILE), "The test config file must not exist.");
+
+ Mock> loggerMock = new();
+ SetLoggerForCliConfigGenerator(loggerMock.Object);
+
+ AutoConfigSimulateOptions options = new(config: MISSING_CONFIG_FILE);
+
+ // Act
+ bool success = TrySimulateAutoentities(options, _runtimeConfigLoader!, _fileSystem!);
+
+ // Assert: the loader already reported the missing file, so no duplicate generic error.
+ Assert.IsFalse(success);
+ AssertErrorNotLogged(loggerMock, "Failed to read the config file");
+ }
+
+ ///
+ /// Tests that an @env() reference which could not be resolved is rejected with an actionable
+ /// message instead of being sent to the database as a literal. Unresolved references survive the
+ /// config load because it runs in Ignore mode, so this check is what catches them.
+ ///
+ [TestMethod]
+ public void TestSimulateAutoentities_UnresolvedEnvVarInConnectionString_Fails()
+ {
+ // Arrange: a config whose connection string references an environment variable that is not set.
+ ClearOpenTelemetryEnvironmentVariables();
+ Environment.SetEnvironmentVariable(UNSET_ENV_VAR_NAME, null);
+
+ InitOptions initOptions = CreateInitOptionsForMsSql(
+ "Server=tcp:127.0.0.1,1;User ID=sa;Password=@env('" + UNSET_ENV_VAR_NAME + "');Connect Timeout=1;");
Assert.IsTrue(TryGenerateConfig(initOptions, _runtimeConfigLoader!, _fileSystem!));
+ AutoConfigOptions autoConfigOptions = new(
+ definitionName: "books-filter",
+ patternsInclude: new[] { "dbo.books" },
+ config: TEST_RUNTIME_CONFIG_FILE);
+ Assert.IsTrue(ConfigGenerator.TryConfigureAutoentities(autoConfigOptions, _runtimeConfigLoader!, _fileSystem!));
+
+ Mock> loggerMock = new();
+ SetLoggerForCliConfigGenerator(loggerMock.Object);
+
AutoConfigSimulateOptions options = new(config: TEST_RUNTIME_CONFIG_FILE);
// Act
@@ -62,6 +219,8 @@ public void TestSimulateAutoentities_NoAutoentitiesDefined()
// Assert
Assert.IsFalse(success);
+ AssertErrorLogged(loggerMock, "unresolved @env() or @akv() reference");
+ AssertErrorNotLogged(loggerMock, "Failed to query the database");
}
///
@@ -236,4 +395,71 @@ public void TestSimulateAutoentities_WithNonMatchingFilter_OutputsNoMatches()
StringAssert.Contains(output, "Matches: 0", "Output should show zero matches.");
StringAssert.Contains(output, "(no matches)", "Output should show the 'no matches' message.");
}
+
+ ///
+ /// Creates the init options used to generate an MSSQL config with the given connection string.
+ ///
+ /// The connection string written to the generated config.
+ private static InitOptions CreateInitOptionsForMsSql(string connectionString)
+ {
+ return new(
+ databaseType: DatabaseType.MSSQL,
+ connectionString: connectionString,
+ cosmosNoSqlDatabase: null,
+ cosmosNoSqlContainer: null,
+ graphQLSchemaPath: null,
+ setSessionContext: false,
+ hostMode: HostMode.Development,
+ corsOrigin: new List(),
+ authenticationProvider: EasyAuthType.AppService.ToString(),
+ config: TEST_RUNTIME_CONFIG_FILE);
+ }
+
+ ///
+ /// Unsets the OpenTelemetry environment variables referenced by an init-generated config so the
+ /// tests deterministically exercise the unresolved @env() scenario.
+ ///
+ private static void ClearOpenTelemetryEnvironmentVariables()
+ {
+ foreach (string name in _openTelemetryEnvVarNames)
+ {
+ Environment.SetEnvironmentVariable(name, null);
+ }
+ }
+
+ ///
+ /// Asserts that an error containing the given fragment was logged exactly once.
+ ///
+ /// The mocked logger the command wrote to.
+ /// Fragment expected in the logged error message.
+ private static void AssertErrorLogged(Mock> loggerMock, string expectedMessageFragment)
+ {
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Error,
+ It.IsAny(),
+ It.Is((o, t) => o.ToString()!.Contains(expectedMessageFragment)),
+ It.IsAny(),
+ (Func)It.IsAny