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:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 10 |
|
| Open findings | 8 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -98,10 +98,10 @@ response body; it failed before the fix and passes after. Full Host suite green
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium — re-triaged: stale design doc, Host code is correct |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Open |
|
||||||
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:70-108` |
|
| Location | `src/ScadaLink.Host/Actors/AkkaHostedService.cs:70-108`, `docs/requirements/Component-Host.md` REQ-HOST-6 |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
|
|
||||||
@@ -126,7 +126,21 @@ add the plugin packages and HOCON. Either way, code and doc must agree.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
_Verified 2026-05-16, left Open — re-triaged._ The finding is accurate: a repo-wide
|
||||||
|
search confirms there are **no** `PersistentActor` / `ReceivePersistentActor`
|
||||||
|
subclasses anywhere in `src/`, no `akka.persistence` section in the HOCON built by
|
||||||
|
`AkkaHostedService.StartAsync`, and `ScadaLink.Host.csproj` references no persistence
|
||||||
|
plugin packages. The system deliberately uses component-owned SQLite storage
|
||||||
|
services instead. The **Host code is therefore correct and internally consistent** —
|
||||||
|
the only defect is that `docs/requirements/Component-Host.md` REQ-HOST-6 and its
|
||||||
|
Dependencies list still mandate Akka.Persistence, a subsystem that does not (and is
|
||||||
|
not intended to) exist. The sole fix is editing that design document, which lies
|
||||||
|
outside this resolution task's permitted edit scope (`src/ScadaLink.Host`,
|
||||||
|
`tests/ScadaLink.Host.Tests`, `code-reviews/Host/findings.md`). No code or test
|
||||||
|
change can resolve a stale-doc finding. Left **Open** and surfaced for the design-doc
|
||||||
|
owner: REQ-HOST-6 must drop the Akka.Persistence requirement (and the
|
||||||
|
`Akka.Persistence.Hosting` Dependencies entry), stating that node-local persistence
|
||||||
|
is provided by component-owned SQLite storage services.
|
||||||
|
|
||||||
### Host-003 — Secrets committed in plaintext in `appsettings.Central.json`
|
### Host-003 — Secrets committed in plaintext in `appsettings.Central.json`
|
||||||
|
|
||||||
@@ -134,7 +148,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Host/appsettings.Central.json:20-31` |
|
| Location | `src/ScadaLink.Host/appsettings.Central.json:20-31` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -159,7 +173,29 @@ environment.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Root cause confirmed against
|
||||||
|
`appsettings.Central.json`: the committed file carried real-looking secrets in
|
||||||
|
plaintext — SQL Server passwords (`Password=ScadaLink_Dev1#`) in both connection
|
||||||
|
strings, an LDAP service-account password, and a JWT signing key. Fix: all four
|
||||||
|
secrets were removed from the committed file and replaced with non-functional
|
||||||
|
`${...}` placeholder references (`ConfigurationDb` / `MachineDataDb`,
|
||||||
|
`LdapServiceAccountPassword`, `JwtSigningKey`). A new top-level `_secrets` note
|
||||||
|
documents that the Host's configuration builder (`AddEnvironmentVariables()`)
|
||||||
|
overlays the real values supplied via environment variables
|
||||||
|
(`ScadaLink__Database__ConfigurationDb`, `ScadaLink__Database__MachineDataDb`,
|
||||||
|
`ScadaLink__Security__LdapServiceAccountPassword`,
|
||||||
|
`ScadaLink__Security__JwtSigningKey`); the placeholders are intentionally invalid so
|
||||||
|
a misconfigured deployment fails loudly rather than silently using a committed key.
|
||||||
|
Regression test class `ConfigSecretsTests` asserts the committed file carries no
|
||||||
|
plaintext `Password=` value, no committed LDAP service-account password, and no
|
||||||
|
committed JWT signing key; all three tests failed before the fix and pass after.
|
||||||
|
Tests that drive the full `Program` startup pipeline against the real SQL provider
|
||||||
|
(`HealthCheckTests`, `HostStartupTests.CentralRole_StartsWithoutError`) were adapted
|
||||||
|
to supply the local dev connection strings themselves via the new
|
||||||
|
`CentralDbTestEnvironment` test helper (environment variables) — they must no longer
|
||||||
|
depend on committed secrets. Note: the `docker/central-node-*/appsettings.Central.json`
|
||||||
|
files still contain the same dev secrets but lie outside this task's permitted edit
|
||||||
|
scope; they should receive the same treatment in a follow-up.
|
||||||
|
|
||||||
### Host-004 — Site seed-node list points at the gRPC port, not a remoting port
|
### Host-004 — Site seed-node list points at the gRPC port, not a remoting port
|
||||||
|
|
||||||
@@ -167,7 +203,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Medium |
|
| Severity | Medium |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.Host/appsettings.Site.json:10-19` |
|
| Location | `src/ScadaLink.Host/appsettings.Site.json:10-19` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -190,7 +226,23 @@ to reject a seed node whose port equals this node's `GrpcPort`.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`). Root cause confirmed against
|
||||||
|
`appsettings.Site.json`: with `Node:RemotingPort = 8082` and `Node:GrpcPort = 8083`,
|
||||||
|
the second `Cluster:SeedNodes` entry was `akka.tcp://scadalink@localhost:8083` — the
|
||||||
|
Kestrel HTTP/2 gRPC port, not an Akka.Remote endpoint. `StartupValidator` only
|
||||||
|
checked seed-node *count* (≥2), so the misconfiguration passed silently. Fix, two
|
||||||
|
parts: (1) the shipped `appsettings.Site.json` second seed entry was corrected to a
|
||||||
|
remoting port (`localhost:8084`); (2) `StartupValidator.Validate` was extended — for
|
||||||
|
`Site` nodes it now parses each seed node's TCP port (via a new `SeedNodePort`
|
||||||
|
helper) and rejects any entry whose port equals the node's `GrpcPort`, using the
|
||||||
|
resolved GrpcPort including the `8083` `NodeOptions` default when the key is absent.
|
||||||
|
The seed-node-count check was hoisted above the Site block so the new rule can reuse
|
||||||
|
the parsed list. Regression tests in `StartupValidatorTests`:
|
||||||
|
`Site_SeedNodeOnGrpcPort_FailsValidation`,
|
||||||
|
`Site_SeedNodeOnDefaultGrpcPort_FailsValidation` (default-8083 path),
|
||||||
|
`Site_SeedNodesOnRemotingPort_PassesValidation`, and
|
||||||
|
`Central_SeedNodeOnPort8083_PassesValidation` (rule is Site-only) — all failed
|
||||||
|
appropriately before the fix and pass after.
|
||||||
|
|
||||||
### Host-005 — Blocking sync-over-async (`GetAwaiter().GetResult()`) inside `StartAsync`
|
### Host-005 — Blocking sync-over-async (`GetAwaiter().GetResult()`) inside `StartAsync`
|
||||||
|
|
||||||
|
|||||||
@@ -40,23 +40,55 @@ public static class StartupValidator
|
|||||||
errors.Add("ScadaLink:Security:JwtSigningKey required for Central");
|
errors.Add("ScadaLink:Security:JwtSigningKey required for Central");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get<List<string>>();
|
||||||
|
if (seedNodes == null || seedNodes.Count < 2)
|
||||||
|
errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries");
|
||||||
|
|
||||||
if (role == "Site")
|
if (role == "Site")
|
||||||
{
|
{
|
||||||
var grpcPortStr = nodeSection["GrpcPort"];
|
var grpcPortStr = nodeSection["GrpcPort"];
|
||||||
if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out var gp) || gp < 1 || gp > 65535))
|
int grpcPort = 8083; // NodeOptions default when the key is absent
|
||||||
|
if (grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535))
|
||||||
errors.Add("ScadaLink:Node:GrpcPort must be 1-65535");
|
errors.Add("ScadaLink:Node:GrpcPort must be 1-65535");
|
||||||
|
|
||||||
var dbSection = configuration.GetSection("ScadaLink:Database");
|
var dbSection = configuration.GetSection("ScadaLink:Database");
|
||||||
if (string.IsNullOrEmpty(dbSection["SiteDbPath"]))
|
if (string.IsNullOrEmpty(dbSection["SiteDbPath"]))
|
||||||
errors.Add("ScadaLink:Database:SiteDbPath required for Site nodes");
|
errors.Add("ScadaLink:Database:SiteDbPath required for Site nodes");
|
||||||
}
|
|
||||||
|
|
||||||
var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get<List<string>>();
|
// Host-004: a seed node must reference an Akka.Remote endpoint, never the
|
||||||
if (seedNodes == null || seedNodes.Count < 2)
|
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's
|
||||||
errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries");
|
// GrpcPort would make a joining node attempt an Akka.Remote TCP
|
||||||
|
// association against the gRPC listener and fail.
|
||||||
|
if (seedNodes != null)
|
||||||
|
{
|
||||||
|
foreach (var seed in seedNodes)
|
||||||
|
{
|
||||||
|
if (SeedNodePort(seed) == grpcPort)
|
||||||
|
errors.Add(
|
||||||
|
$"ScadaLink:Cluster:SeedNodes entry '{seed}' must not target the gRPC port " +
|
||||||
|
$"({grpcPort}); seed nodes must reference Akka remoting ports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}");
|
$"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the TCP port from an Akka seed-node address of the form
|
||||||
|
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
|
||||||
|
/// </summary>
|
||||||
|
private static int SeedNodePort(string seedNode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(seedNode))
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
var lastColon = seedNode.LastIndexOf(':');
|
||||||
|
if (lastColon < 0 || lastColon == seedNode.Length - 1)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
return int.TryParse(seedNode[(lastColon + 1)..], out var port) ? port : -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
"FailureDetectionThreshold": "00:00:10",
|
"FailureDetectionThreshold": "00:00:10",
|
||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1
|
||||||
},
|
},
|
||||||
|
"_secrets": "Host-003: Secrets are NOT committed in this file. Supply them via environment variables, which the Host's configuration builder (AddEnvironmentVariables) overlays over this file. Required: ScadaLink__Database__ConfigurationDb, ScadaLink__Database__MachineDataDb, ScadaLink__Security__LdapServiceAccountPassword, ScadaLink__Security__JwtSigningKey. The ${...} placeholders below are intentionally non-functional and must be overridden per environment.",
|
||||||
"Database": {
|
"Database": {
|
||||||
"ConfigurationDb": "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true",
|
"ConfigurationDb": "${SCADALINK_CONFIGURATIONDB_CONNECTION_STRING}",
|
||||||
"MachineDataDb": "Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true"
|
"MachineDataDb": "${SCADALINK_MACHINEDATADB_CONNECTION_STRING}"
|
||||||
},
|
},
|
||||||
"Security": {
|
"Security": {
|
||||||
"LdapServer": "localhost",
|
"LdapServer": "localhost",
|
||||||
@@ -27,8 +28,8 @@
|
|||||||
"AllowInsecureLdap": true,
|
"AllowInsecureLdap": true,
|
||||||
"LdapSearchBase": "dc=scadalink,dc=local",
|
"LdapSearchBase": "dc=scadalink,dc=local",
|
||||||
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
|
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
|
||||||
"LdapServiceAccountPassword": "password",
|
"LdapServiceAccountPassword": "${SCADALINK_LDAP_SERVICE_ACCOUNT_PASSWORD}",
|
||||||
"JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
"JwtSigningKey": "${SCADALINK_JWT_SIGNING_KEY}",
|
||||||
"JwtExpiryMinutes": 15,
|
"JwtExpiryMinutes": 15,
|
||||||
"IdleTimeoutMinutes": 30
|
"IdleTimeoutMinutes": 30
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadalink@localhost:8082",
|
"akka.tcp://scadalink@localhost:8082",
|
||||||
"akka.tcp://scadalink@localhost:8083"
|
"akka.tcp://scadalink@localhost:8084"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "keep-oldest",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
|
|||||||
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();
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
foreach (var d in _disposables)
|
foreach (var d in _disposables)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public class HostStartupTests : IDisposable
|
|||||||
// Set the environment to Central so appsettings.Central.json is loaded,
|
// Set the environment to Central so appsettings.Central.json is loaded,
|
||||||
// and set DOTNET_ENVIRONMENT before the factory creates the host.
|
// and set DOTNET_ENVIRONMENT before the factory creates the host.
|
||||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
|
// Host-003: connection strings are externalised; supply them via env vars.
|
||||||
|
using var dbEnv = new CentralDbTestEnvironment();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
|||||||
@@ -254,6 +254,62 @@ public class StartupValidatorTests
|
|||||||
Assert.Null(ex);
|
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]
|
[Fact]
|
||||||
public void MultipleErrors_AllReported()
|
public void MultipleErrors_AllReported()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user