fix(host): resolve Host-003,004 — replace plaintext secrets with env placeholders, validate site seed-node ports; re-triage Host-002
This commit is contained in:
41
tests/ScadaLink.Host.Tests/CentralDbTestEnvironment.cs
Normal file
41
tests/ScadaLink.Host.Tests/CentralDbTestEnvironment.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-003: <c>appsettings.Central.json</c> no longer commits database connection
|
||||
/// strings — they are externalised to environment variables. Tests that exercise the
|
||||
/// full <c>Program</c> startup pipeline against the real SQL provider must therefore
|
||||
/// supply the local dev connection strings the way a deployment would: via
|
||||
/// environment variables (<c>Program</c>'s configuration builder calls
|
||||
/// <c>AddEnvironmentVariables()</c>).
|
||||
///
|
||||
/// Dispose restores the previous values so tests stay isolated.
|
||||
/// </summary>
|
||||
internal sealed class CentralDbTestEnvironment : IDisposable
|
||||
{
|
||||
// Local dev/test database — same credentials the infra docker-compose stack uses.
|
||||
// This is a test fixture value, not a committed production secret.
|
||||
private const string ConfigurationDb =
|
||||
"Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true";
|
||||
private const string MachineDataDb =
|
||||
"Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true";
|
||||
|
||||
private const string ConfigKey = "ScadaLink__Database__ConfigurationDb";
|
||||
private const string MachineKey = "ScadaLink__Database__MachineDataDb";
|
||||
|
||||
private readonly string? _previousConfig;
|
||||
private readonly string? _previousMachine;
|
||||
|
||||
public CentralDbTestEnvironment()
|
||||
{
|
||||
_previousConfig = Environment.GetEnvironmentVariable(ConfigKey);
|
||||
_previousMachine = Environment.GetEnvironmentVariable(MachineKey);
|
||||
Environment.SetEnvironmentVariable(ConfigKey, ConfigurationDb);
|
||||
Environment.SetEnvironmentVariable(MachineKey, MachineDataDb);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(ConfigKey, _previousConfig);
|
||||
Environment.SetEnvironmentVariable(MachineKey, _previousMachine);
|
||||
}
|
||||
}
|
||||
89
tests/ScadaLink.Host.Tests/ConfigSecretsTests.cs
Normal file
89
tests/ScadaLink.Host.Tests/ConfigSecretsTests.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Host-003 regression: secrets must not be committed in plaintext in the
|
||||
/// shipped <c>appsettings.Central.json</c>. Connection-string passwords, the LDAP
|
||||
/// service-account password and the JWT signing key must be supplied via
|
||||
/// environment variables (or another secret store) at deployment time — the
|
||||
/// committed file may only carry non-sensitive structural defaults or
|
||||
/// placeholder values.
|
||||
/// </summary>
|
||||
public class ConfigSecretsTests
|
||||
{
|
||||
private static string FindHostProjectDirectory()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ScadaLink.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException("Could not locate src/ScadaLink.Host");
|
||||
}
|
||||
|
||||
private static JsonElement ScadaLinkSection()
|
||||
{
|
||||
var path = Path.Combine(FindHostProjectDirectory(), "appsettings.Central.json");
|
||||
var json = File.ReadAllText(path);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.GetProperty("ScadaLink").Clone();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralConfig_ConnectionStrings_ContainNoPlaintextPassword()
|
||||
{
|
||||
var db = ScadaLinkSection().GetProperty("Database");
|
||||
foreach (var prop in db.EnumerateObject())
|
||||
{
|
||||
var value = prop.Value.GetString() ?? string.Empty;
|
||||
// A committed connection string must not carry a literal Password= value.
|
||||
// Either the password is delivered via an environment variable or the
|
||||
// whole connection string is. A placeholder reference is acceptable.
|
||||
var idx = value.IndexOf("Password=", StringComparison.OrdinalIgnoreCase);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var after = value[(idx + "Password=".Length)..];
|
||||
var literal = after.Split(';')[0];
|
||||
Assert.True(
|
||||
literal.Length == 0 || literal.Contains('{') || literal.Contains('$'),
|
||||
$"appsettings.Central.json '{prop.Name}' contains a plaintext Password value '{literal}'. " +
|
||||
"Move the secret to an environment variable.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralConfig_LdapServiceAccountPassword_IsNotCommitted()
|
||||
{
|
||||
var security = ScadaLinkSection().GetProperty("Security");
|
||||
if (security.TryGetProperty("LdapServiceAccountPassword", out var pw))
|
||||
{
|
||||
var value = pw.GetString() ?? string.Empty;
|
||||
Assert.True(
|
||||
value.Length == 0 || value.Contains('{') || value.Contains('$'),
|
||||
$"appsettings.Central.json carries a plaintext LdapServiceAccountPassword '{value}'. " +
|
||||
"Move it to an environment variable.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralConfig_JwtSigningKey_IsNotCommitted()
|
||||
{
|
||||
var security = ScadaLinkSection().GetProperty("Security");
|
||||
if (security.TryGetProperty("JwtSigningKey", out var key))
|
||||
{
|
||||
var value = key.GetString() ?? string.Empty;
|
||||
Assert.True(
|
||||
value.Length == 0 || value.Contains('{') || value.Contains('$'),
|
||||
$"appsettings.Central.json carries a committed JwtSigningKey '{value}'. " +
|
||||
"A committed signing key lets anyone with repo access forge session tokens. " +
|
||||
"Move it to an environment variable.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ public class HealthCheckTests : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _disposables = new();
|
||||
|
||||
public HealthCheckTests()
|
||||
{
|
||||
// Host-003: connection strings are externalised; supply them via env vars.
|
||||
_disposables.Add(new CentralDbTestEnvironment());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
|
||||
@@ -28,6 +28,8 @@ public class HostStartupTests : IDisposable
|
||||
// Set the environment to Central so appsettings.Central.json is loaded,
|
||||
// and set DOTNET_ENVIRONMENT before the factory creates the host.
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
// Host-003: connection strings are externalised; supply them via env vars.
|
||||
using var dbEnv = new CentralDbTestEnvironment();
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
@@ -254,6 +254,62 @@ public class StartupValidatorTests
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_SeedNodeOnGrpcPort_FailsValidation()
|
||||
{
|
||||
// Host-004 regression: a site seed node must reference an Akka remoting
|
||||
// endpoint, never the Kestrel HTTP/2 gRPC port. A seed node whose port
|
||||
// equals this node's GrpcPort would make a joining node attempt an
|
||||
// Akka.Remote TCP association against the gRPC listener and fail.
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaLink:Node:GrpcPort"] = "8083";
|
||||
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node1:8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("must not target the gRPC port", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_SeedNodeOnDefaultGrpcPort_FailsValidation()
|
||||
{
|
||||
// GrpcPort is absent here, so the NodeOptions default of 8083 applies.
|
||||
// A seed node on 8083 must still be rejected.
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node2:8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("must not target the gRPC port", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_SeedNodesOnRemotingPort_PassesValidation()
|
||||
{
|
||||
// Two distinct site nodes, both seed entries on the remoting port (8082).
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaLink:Node:GrpcPort"] = "8083";
|
||||
values["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@site-a-node1:8082";
|
||||
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node2:8082";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_SeedNodeOnPort8083_PassesValidation()
|
||||
{
|
||||
// The gRPC-port rule applies to Site nodes only. A Central node has no
|
||||
// GrpcPort, so a seed node on 8083 must not be rejected.
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@central-node2:8083";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleErrors_AllReported()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user