diff --git a/docs/Authentication.md b/docs/Authentication.md
index 3fc0074..d864bb3 100644
--- a/docs/Authentication.md
+++ b/docs/Authentication.md
@@ -105,6 +105,8 @@ authorization code consumes.
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
+The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the credential store is never written relative to the launch working directory on a non-Windows host. The production hosts pin the explicit Windows path in `appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative) `SqlitePath` so a bad override fails fast at startup rather than scattering the store by launch CWD (SEC-01).
+
### Connection factory
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`:
diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md
index 005eb5a..7304b7e 100644
--- a/docs/GatewayConfiguration.md
+++ b/docs/GatewayConfiguration.md
@@ -93,12 +93,15 @@ Environment variables use the normal .NET double-underscore form. For example,
| Option | Default | Description |
|--------|---------|-------------|
| `MxGateway:Authentication:Mode` | `ApiKey` | Selects public gRPC authentication. Supported values are `ApiKey` and `Disabled`. `Disabled` bypasses API-key verification and is for local development only. |
-| `MxGateway:Authentication:SqlitePath` | `C:\ProgramData\MxGateway\gateway-auth.db` | SQLite database path for API-key records and audit rows when API-key authentication is enabled. |
+| `MxGateway:Authentication:SqlitePath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) | SQLite database path for API-key records and audit rows when API-key authentication is enabled. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so the credential store never lands in the launch working directory on a non-Windows host; the production hosts pin the explicit Windows path in `appsettings.json`, which overrides the code default. |
| `MxGateway:Authentication:PepperSecretName` | `MxGateway:ApiKeyPepper` | Configuration key used to read the HMAC pepper for API-key secret hashing. The dashboard effective configuration redacts this value. |
| `MxGateway:Authentication:RunMigrationsOnStartup` | `true` | Runs SQLite auth schema migrations at gateway startup when API-key authentication is enabled. |
When `Mode` is `ApiKey`, `SqlitePath` and `PepperSecretName` must be present.
-`SqlitePath` must be a valid filesystem path.
+`SqlitePath` must be a valid filesystem path and must be **rooted** (absolute):
+the validator rejects a non-rooted path so a relative override cannot silently
+resolve against the working directory and scatter the credential store by launch
+CWD (SEC-01).
## Worker Options
@@ -177,7 +180,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Reserved display control for tag values. The dashboard does not show full tag values by default. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Admin` (read/write, API-key CRUD) or `Viewer` (read-only). A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
-| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. |
+| `MxGateway:Dashboard:DisableLogin` | `false` | Dev/test only. When `true`, replaces the cookie authentication handler with `DashboardAutoLoginAuthenticationHandler`, which auto-authenticates every dashboard request — including requests from remote browsers, not just loopback — as `AutoLoginUser` holding both `Administrator` and `Viewer` roles. No login form, LDAP bind, or cookie is involved. A loud one-time startup warning is logged. Differs from `AllowAnonymousLocalhost`: `DisableLogin` mints a real authenticated principal (so role-gated write affordances appear), whereas `AllowAnonymousLocalhost` satisfies the authorization requirement on loopback only without minting a principal (write affordances stay hidden). Never enable in production. **Production hard-stop (SEC-04):** when the host runs in the `Production` environment and `DisableLogin` is `true`, startup validation fails and the process aborts — the flag is only accepted outside Production, where the one-time startup warning still fires. |
| `MxGateway:Dashboard:AutoLoginUser` | `(null)` | Username stamped on the synthetic principal when `DisableLogin` is `true`. Default `(null)` — a null or blank value falls back to `multi-role`. Has no effect when `DisableLogin` is `false`. |
`SnapshotIntervalMilliseconds` must be greater than zero. `RecentFaultLimit`
@@ -219,6 +222,31 @@ When the dashboard is enabled, three hubs are mapped under `/hubs/*`:
token for the calling user; the Blazor pages use it via
`DashboardHubConnectionFactory` to authenticate the SignalR connection.
+## LDAP Options (Dashboard Login)
+
+The `MxGateway:Ldap` section configures the LDAP bind used by dashboard `/login`
+(the gRPC API-key model is separate). The shipped defaults are the shared
+dev/test GLAuth posture (`glauth.md`), not a production posture.
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `MxGateway:Ldap:Enabled` | `true` | Enables LDAP-backed dashboard login. |
+| `MxGateway:Ldap:Server` | `localhost` | LDAP server host. Dev points at the shared GLAuth instance (`10.100.0.35`). |
+| `MxGateway:Ldap:Port` | `3893` | LDAP server port. Must be between `1` and `65535`. |
+| `MxGateway:Ldap:Transport` | `None` | Connection transport: `None` (plaintext), `StartTls` (upgrade), or `Ldaps` (implicit TLS). The dev default is `None` because the shared GLAuth instance has LDAPS disabled and binds send cleartext. **Production hard-stop (SEC-06):** when the host runs in the `Production` environment, `Transport` must not be `None` — startup validation fails otherwise. Deployed hosts must set `Ldaps` or `StartTls`. |
+| `MxGateway:Ldap:AllowInsecure` | `true` | Permits a plaintext bind. Must be `true` when `Transport` is `None`; set `false` (with `Ldaps`/`StartTls`) in production. |
+| `MxGateway:Ldap:SearchBase` | `dc=zb,dc=local` | Search base DN. |
+| `MxGateway:Ldap:ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | Bind DN for the search account. |
+| `MxGateway:Ldap:ServiceAccountPassword` | `serviceaccount123` | Search-account password. **Dev-only committed value.** On the deployed hosts, do not ship this in `appsettings.json`; supply it out-of-band via the env-var override `MxGateway__Ldap__ServiceAccountPassword` (double-underscore form, as set on the NSSM-wrapped services). The committed `serviceaccount123` is a shared dev credential for the local GLAuth instance and should be rotated; production must use a secret it does not share with dev. |
+| `MxGateway:Ldap:UserNameAttribute` | `cn` | LDAP attribute holding the login user name. |
+| `MxGateway:Ldap:DisplayNameAttribute` | `cn` | LDAP attribute holding the display name. |
+| `MxGateway:Ldap:GroupAttribute` | `memberOf` | LDAP attribute enumerating group membership (mapped to dashboard roles via `MxGateway:Dashboard:GroupToRole`). |
+
+When LDAP is enabled, `Server`, `SearchBase`, `ServiceAccountDn`,
+`ServiceAccountPassword`, and the attribute names must be non-blank, and `Port`
+must be in range. See `glauth.md` for the shared dev instance and the
+dev→production hardening posture.
+
## Protocol Options
| Option | Default | Description |
@@ -238,7 +266,7 @@ at startup.
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. |
| `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. |
| `MxGateway:Galaxy:PersistSnapshot` | `true` | Persists the latest successful Galaxy browse dataset to disk. When `true`, the cache reloads that snapshot at startup so clients can still browse last-known data while the Galaxy database is unreachable. The restored data is served with `Stale` status until a live query confirms it. |
-| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). |
+| `MxGateway:Galaxy:SnapshotCachePath` | `C:\ProgramData\MxGateway\galaxy-snapshot.json` | File path for the persisted Galaxy browse snapshot. Ignored when `PersistSnapshot` is `false`. The snapshot is written atomically (temp file plus rename). Set an **absolute** path — this option is bound by the shared `ZB.MOM.WW.GalaxyRepository` package (not by `GatewayOptions`), so the gateway validator does not enforce rooting on it; a relative value would resolve against the launch working directory (SEC-01). |
See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and
behavior.
@@ -442,7 +470,7 @@ them.
| Option | Default | Purpose |
|---|---|---|
-| `Tls:SelfSignedCertPath` | `C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` | Where the generated certificate is persisted |
+| `Tls:SelfSignedCertPath` | derived from `CommonApplicationData` (`C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx` on Windows, `/usr/share/MxGateway/certs/gateway-selfsigned.pfx` or the container equivalent elsewhere) | Where the generated certificate is persisted. The code default is built from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` so a generated private key never lands in the launch working directory on a non-Windows host (SEC-01). Must be **rooted** (absolute); the validator rejects a non-rooted override. |
| `Tls:ValidityYears` | `10` | Lifetime of the generated certificate (validated 1–100) |
| `Tls:AdditionalDnsNames` | `[]` | Extra DNS SANs (e.g. a load-balancer name) |
| `Tls:RegenerateIfExpired` | `true` | Replace an expired persisted certificate instead of failing |
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs
index 534cdef..767daf1 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/AuthenticationOptions.cs
@@ -5,8 +5,18 @@ public sealed class AuthenticationOptions
/// Gets the authentication mode.
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
- /// Gets the SQLite database path for authentication credentials.
- public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
+ ///
+ /// Gets the SQLite database path for authentication credentials. The default is
+ /// derived from
+ /// (C:\ProgramData on Windows, /usr/share or the container equivalent
+ /// elsewhere) so the credential store never lands in the launch working directory on a
+ /// non-Windows host. The production hosts override this with an explicit path in
+ /// appsettings.json; the validator rejects a non-rooted override.
+ ///
+ public string SqlitePath { get; init; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "MxGateway",
+ "gateway-auth.db");
/// Gets the secret manager name for API key pepper.
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
index e5b8813..686b168 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
@@ -9,15 +9,42 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase
+ /// Initializes a new instance of the class for the
+ /// dependency-injection path, deriving the production posture from the host environment.
+ ///
+ /// The host environment.
+ public GatewayOptionsValidator(IHostEnvironment environment)
+ {
+ ArgumentNullException.ThrowIfNull(environment);
+ _isProduction = environment.IsProduction();
+ }
+
+ ///
+ /// Initializes a new instance of the class for unit
+ /// tests and non-DI callers. Defaults to a non-production posture so the production-only
+ /// hard-stops do not fire; pass to exercise them.
+ ///
+ /// Whether to treat the host as running in Production.
+ internal GatewayOptionsValidator(bool isProduction = false)
+ {
+ _isProduction = isProduction;
+ }
+
///
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
{
ValidateAuthentication(options.Authentication, builder);
- ValidateLdap(options.Ldap, builder);
+ ValidateLdap(options.Ldap, builder, _isProduction);
ValidateWorker(options.Worker, builder);
ValidateSessions(options.Sessions, builder);
ValidateEvents(options.Events, builder);
- ValidateDashboard(options.Dashboard, builder);
+ ValidateDashboard(options.Dashboard, builder, _isProduction);
ValidateProtocol(options.Protocol, builder);
ValidateAlarms(options.Alarms, builder);
ValidateTls(options.Tls, builder);
@@ -41,6 +68,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase= 0, message);
}
+ private static void AddIfNotRooted(string? value, string message, ValidationBuilder builder)
+ {
+ // Security-sensitive paths (the auth DB, the self-signed private key) must be absolute:
+ // a non-rooted value silently resolves against the launch working directory, so the store
+ // moves with the CWD and can leak into the source tree. Reject rather than auto-root —
+ // silent relocation of a credential store is worse than a boot error. Blank is handled by
+ // AddIfBlank; an empty value is not treated as non-rooted here.
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return;
+ }
+
+ if (!Path.IsPathRooted(value))
+ {
+ builder.Add(message);
+ }
+ }
+
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
{
if (string.IsNullOrWhiteSpace(value))
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs
index 9be84a3..4099749 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs
@@ -7,9 +7,19 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
///
public sealed class TlsOptions
{
- /// Path to the persisted self-signed PFX. Reused across restarts.
- public string SelfSignedCertPath { get; init; } =
- @"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx";
+ ///
+ /// Path to the persisted self-signed PFX. Reused across restarts. The default is derived
+ /// from (C:\ProgramData
+ /// on Windows, /usr/share or the container equivalent elsewhere) so a generated
+ /// private key never lands in the launch working directory on a non-Windows host. The
+ /// production hosts override this with an explicit path; the validator rejects a non-rooted
+ /// override.
+ ///
+ public string SelfSignedCertPath { get; init; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "MxGateway",
+ "certs",
+ "gateway-selfsigned.pfx");
/// Lifetime in years of a freshly generated certificate.
public int ValidityYears { get; init; } = 10;
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
index e8646eb..159203c 100644
--- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
+using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
@@ -548,4 +549,138 @@ public sealed class GatewayOptionsValidatorTests
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
Assert.True(result.Succeeded);
}
+
+ // -------------------------------------------------------------------------
+ // SEC-01: security-sensitive paths must be rooted (absolute)
+ // -------------------------------------------------------------------------
+
+ private static GatewayOptions CloneWithAuthentication(GatewayOptions source, AuthenticationOptions authentication)
+ => new()
+ {
+ Authentication = authentication,
+ Ldap = source.Ldap,
+ Worker = source.Worker,
+ Sessions = source.Sessions,
+ Events = source.Events,
+ Dashboard = source.Dashboard,
+ Protocol = source.Protocol,
+ Alarms = source.Alarms,
+ Tls = source.Tls,
+ };
+
+ /// Verifies the default (CommonApplicationData-derived) auth DB path is rooted and passes validation.
+ [Fact]
+ public void Validate_Succeeds_WithDefaultRootedSqlitePath()
+ {
+ // The default AuthenticationOptions.SqlitePath is derived from CommonApplicationData,
+ // which is rooted on every host OS.
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, ValidOptions());
+ Assert.True(result.Succeeded);
+ }
+
+ /// Verifies a non-rooted fails validation.
+ [Fact]
+ public void Validate_Fails_WhenSqlitePathNotRooted()
+ {
+ GatewayOptions options = CloneWithAuthentication(
+ ValidOptions(),
+ new AuthenticationOptions { SqlitePath = "gateway-auth.db" });
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
+ }
+
+ /// Verifies a non-rooted fails validation.
+ [Fact]
+ public void Validate_Fails_WhenSelfSignedCertPathNotRooted()
+ {
+ GatewayOptions options = CloneWithTls(
+ ValidOptions(),
+ new TlsOptions { SelfSignedCertPath = "gateway-selfsigned.pfx" });
+ ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains("MxGateway:Tls:SelfSignedCertPath") && f.Contains("rooted"));
+ }
+
+ // -------------------------------------------------------------------------
+ // SEC-04: DisableLogin production guard
+ // -------------------------------------------------------------------------
+
+ private static GatewayOptions CloneWithDashboard(GatewayOptions source, DashboardOptions dashboard)
+ => new()
+ {
+ Authentication = source.Authentication,
+ Ldap = source.Ldap,
+ Worker = source.Worker,
+ Sessions = source.Sessions,
+ Events = source.Events,
+ Dashboard = dashboard,
+ Protocol = source.Protocol,
+ Alarms = source.Alarms,
+ Tls = source.Tls,
+ };
+
+ /// Verifies set to aborts startup in Production.
+ [Fact]
+ public void Validate_Fails_WhenDisableLoginTrueInProduction()
+ {
+ GatewayOptions options = CloneWithDashboard(
+ ValidOptions(),
+ new DashboardOptions { DisableLogin = true });
+ ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
+ Assert.True(result.Failed);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains("MxGateway:Dashboard:DisableLogin") && f.Contains("Production"));
+ }
+
+ /// Verifies set to is accepted outside Production.
+ [Fact]
+ public void Validate_Succeeds_WhenDisableLoginTrueInDevelopment()
+ {
+ GatewayOptions options = CloneWithDashboard(
+ ValidOptions(),
+ new DashboardOptions { DisableLogin = true });
+ ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, options);
+ Assert.True(result.Succeeded);
+ }
+
+ // -------------------------------------------------------------------------
+ // SEC-06: LDAP plaintext transport production guard
+ // -------------------------------------------------------------------------
+
+ /// Verifies plaintext LDAP transport (None) aborts startup in Production.
+ [Fact]
+ public void Validate_Fails_WhenLdapTransportNoneInProduction()
+ {
+ // The class default LDAP options ship Transport=None + AllowInsecure=true (dev posture).
+ ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, ValidOptions());
+ Assert.True(result.Failed);
+ Assert.Contains(
+ result.Failures!,
+ f => f.Contains("MxGateway:Ldap:Transport") && f.Contains("Production"));
+ }
+
+ /// Verifies plaintext LDAP transport (None) is accepted outside Production.
+ [Fact]
+ public void Validate_Succeeds_WhenLdapTransportNoneInDevelopment()
+ {
+ ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: false).Validate(null, ValidOptions());
+ Assert.True(result.Succeeded);
+ }
+
+ /// Verifies secure LDAP transport (Ldaps) passes validation in Production.
+ [Fact]
+ public void Validate_Succeeds_WhenLdapTransportLdapsInProduction()
+ {
+ GatewayOptions options = CloneWithLdap(
+ ValidOptions(),
+ new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps, AllowInsecure = false });
+ ValidateOptionsResult result = new GatewayOptionsValidator(isProduction: true).Validate(null, options);
+ Assert.True(result.Succeeded);
+ }
}
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs
new file mode 100644
index 0000000..4505874
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/ProjectStructure/GatewayTreeHygieneTests.cs
@@ -0,0 +1,54 @@
+namespace ZB.MOM.WW.MxGateway.Tests.ProjectStructure;
+
+///
+/// Repo-hygiene guards over the source tree. See SEC-01: a Windows-absolute default path that
+/// resolves relative to the launch working directory silently materializes a SQLite credential
+/// store inside the tree. This test fails if any such *.db file appears under src/.
+///
+public sealed class GatewayTreeHygieneTests
+{
+ /// Verifies no SQLite database files are committed/materialized under the source tree (build output excluded).
+ [Fact]
+ public void SourceTree_ContainsNoSqliteDatabaseFiles()
+ {
+ DirectoryInfo sourceRoot = FindSourceRoot();
+
+ string[] databaseFiles = Directory
+ .EnumerateFiles(sourceRoot.FullName, "*.db", SearchOption.AllDirectories)
+ .Where(path => !IsBuildOutput(path))
+ .ToArray();
+
+ Assert.True(
+ databaseFiles.Length == 0,
+ "Unexpected *.db file(s) found under the source tree (build output excluded): "
+ + string.Join(", ", databaseFiles)
+ + ". A SQLite DB in the tree usually means a Windows-absolute default path resolved "
+ + "relative to the working directory; see SEC-01.");
+ }
+
+ // Build output (bin/obj) is regenerated by test/compile runs and is gitignored, so exclude it.
+ private static bool IsBuildOutput(string path)
+ {
+ string normalized = path.Replace('\\', '/');
+ return normalized.Contains("/bin/", StringComparison.Ordinal)
+ || normalized.Contains("/obj/", StringComparison.Ordinal);
+ }
+
+ // The directory holding ZB.MOM.WW.MxGateway.slnx is the src/ root of the working tree.
+ private static DirectoryInfo FindSourceRoot()
+ {
+ DirectoryInfo? current = new(AppContext.BaseDirectory);
+
+ while (current is not null)
+ {
+ if (File.Exists(Path.Combine(current.FullName, "ZB.MOM.WW.MxGateway.slnx")))
+ {
+ return current;
+ }
+
+ current = current.Parent;
+ }
+
+ throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.MxGateway.slnx from the test output directory.");
+ }
+}