Phase 1 WP-11–22: Host infrastructure, Blazor Server UI, and integration tests

Host infrastructure (WP-11–17):
- StartupValidator with 19 validation rules
- /health/ready endpoint with DB + Akka health checks
- Akka.NET bootstrap via AkkaHostedService (HOCON config, cluster, remoting, SBR)
- Serilog with SiteId/NodeHostname/NodeRole enrichment
- DeadLetterMonitorActor with count tracking
- CoordinatedShutdown wiring (no Environment.Exit)
- Windows Service support (UseWindowsService)

Central UI (WP-18–21):
- Blazor Server shell with Bootstrap 5, role-aware NavMenu
- Login/logout flow (LDAP auth → JWT → HTTP-only cookie)
- CookieAuthenticationStateProvider with idle timeout
- LDAP group mapping CRUD page (Admin role)
- Route guards with Authorize attributes per role
- SignalR reconnection overlay for failover

Integration tests (WP-22):
- Startup validation, auth flow, audit transactions, readiness gating
186 tests pass (1 skipped: LDAP integration), zero warnings.
This commit is contained in:
Joseph Doherty
2026-03-16 19:50:59 -04:00
parent cafb7d2006
commit d38356efdb
47 changed files with 2436 additions and 71 deletions

View File

@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Entities.Security;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.IntegrationTests;
/// <summary>
/// WP-22: Audit transactional guarantee — entity change + audit log in same transaction.
/// </summary>
public class AuditTransactionTests : IClassFixture<ScadaLinkWebApplicationFactory>
{
private readonly ScadaLinkWebApplicationFactory _factory;
public AuditTransactionTests(ScadaLinkWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task AuditLog_IsCommittedWithEntityChange_InSameTransaction()
{
using var scope = _factory.Services.CreateScope();
var securityRepo = scope.ServiceProvider.GetRequiredService<ISecurityRepository>();
var auditService = scope.ServiceProvider.GetRequiredService<IAuditService>();
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
// Add a mapping and an audit log entry in the same unit of work
var mapping = new LdapGroupMapping("test-group-audit", "Admin");
await securityRepo.AddMappingAsync(mapping);
await auditService.LogAsync(
user: "test-user",
action: "Create",
entityType: "LdapGroupMapping",
entityId: "0", // ID not yet assigned
entityName: "test-group-audit",
afterState: new { Group = "test-group-audit", Role = "Admin" });
// Both should be in the change tracker before saving
var trackedEntities = dbContext.ChangeTracker.Entries().Count(e => e.State == EntityState.Added);
Assert.True(trackedEntities >= 2, "Both entity and audit log should be tracked before SaveChanges");
// Single SaveChangesAsync commits both
await securityRepo.SaveChangesAsync();
// Verify both were persisted
var mappings = await securityRepo.GetAllMappingsAsync();
Assert.Contains(mappings, m => m.LdapGroupName == "test-group-audit");
var auditEntries = await dbContext.AuditLogEntries.ToListAsync();
Assert.Contains(auditEntries, a => a.EntityName == "test-group-audit" && a.Action == "Create");
}
[Fact]
public async Task AuditLog_IsNotPersistedWhenSaveNotCalled()
{
// Create a separate scope so we have a fresh DbContext
using var scope1 = _factory.Services.CreateScope();
var securityRepo = scope1.ServiceProvider.GetRequiredService<ISecurityRepository>();
var auditService = scope1.ServiceProvider.GetRequiredService<IAuditService>();
// Add entity + audit but do NOT call SaveChangesAsync
var mapping = new LdapGroupMapping("orphan-group", "Design");
await securityRepo.AddMappingAsync(mapping);
await auditService.LogAsync("test", "Create", "LdapGroupMapping", "0", "orphan-group", null);
// Dispose scope without saving — simulates a failed transaction
scope1.Dispose();
// In a new scope, verify nothing was persisted
using var scope2 = _factory.Services.CreateScope();
var securityRepo2 = scope2.ServiceProvider.GetRequiredService<ISecurityRepository>();
var dbContext2 = scope2.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var mappings = await securityRepo2.GetAllMappingsAsync();
Assert.DoesNotContain(mappings, m => m.LdapGroupName == "orphan-group");
var auditEntries = await dbContext2.AuditLogEntries.ToListAsync();
Assert.DoesNotContain(auditEntries, a => a.EntityName == "orphan-group");
}
}

View File

@@ -0,0 +1,132 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.CentralUI.Auth;
using ScadaLink.Security;
namespace ScadaLink.IntegrationTests;
/// <summary>
/// WP-22: Auth flow integration tests.
/// Tests that require a running LDAP server are marked with Integration trait.
/// </summary>
public class AuthFlowTests : IClassFixture<ScadaLinkWebApplicationFactory>
{
private readonly ScadaLinkWebApplicationFactory _factory;
public AuthFlowTests(ScadaLinkWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task LoginEndpoint_WithEmptyCredentials_RedirectsToLoginWithError()
{
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("username", ""),
new KeyValuePair<string, string>("password", "")
});
var response = await client.PostAsync("/auth/login", content);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var location = response.Headers.Location?.ToString() ?? "";
Assert.Contains("/login", location);
Assert.Contains("error", location, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task LogoutEndpoint_ClearsCookieAndRedirects()
{
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var response = await client.PostAsync("/auth/logout", null);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var location = response.Headers.Location?.ToString() ?? "";
Assert.Contains("/login", location);
}
[Fact]
public void JwtTokenService_GenerateAndValidate_RoundTrips()
{
using var scope = _factory.Services.CreateScope();
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
var token = jwtService.GenerateToken(
displayName: "Test User",
username: "testuser",
roles: new[] { "Admin", "Design" },
permittedSiteIds: null);
Assert.NotNull(token);
var principal = jwtService.ValidateToken(token);
Assert.NotNull(principal);
var displayName = principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
Assert.Equal("Test User", displayName);
Assert.Equal("testuser", username);
Assert.Contains("Admin", roles);
Assert.Contains("Design", roles);
}
[Fact]
public void JwtTokenService_WithSiteScopes_IncludesSiteIdClaims()
{
using var scope = _factory.Services.CreateScope();
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
var token = jwtService.GenerateToken(
displayName: "Deployer",
username: "deployer1",
roles: new[] { "Deployment" },
permittedSiteIds: new[] { "1", "3" });
var principal = jwtService.ValidateToken(token);
Assert.NotNull(principal);
var siteIds = principal!.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
Assert.Contains("1", siteIds);
Assert.Contains("3", siteIds);
}
[Trait("Category", "Integration")]
[Fact(Skip = "Requires running GLAuth LDAP server (Docker). Run with: docker compose -f infra/docker-compose.yml up -d glauth")]
public async Task LoginEndpoint_WithValidLdapCredentials_SetsCookieAndRedirects()
{
// This test requires the GLAuth test LDAP server running on localhost:3893
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
var content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("username", "admin"),
new KeyValuePair<string, string>("password", "admin")
});
var response = await client.PostAsync("/auth/login", content);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var location = response.Headers.Location?.ToString() ?? "";
Assert.Equal("/", location);
// Verify auth cookie was set
var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault();
Assert.NotNull(setCookieHeader);
Assert.Contains(CookieAuthenticationStateProvider.AuthCookieName, setCookieHeader);
}
}

View File

@@ -0,0 +1,30 @@
using System.Net;
namespace ScadaLink.IntegrationTests;
/// <summary>
/// WP-22: Readiness gating — /health/ready endpoint returns status code.
/// </summary>
public class ReadinessTests : IClassFixture<ScadaLinkWebApplicationFactory>
{
private readonly ScadaLinkWebApplicationFactory _factory;
public ReadinessTests(ScadaLinkWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task HealthReady_ReturnsSuccessStatusCode()
{
using var client = _factory.CreateClient();
var response = await client.GetAsync("/health/ready");
// The endpoint should exist and return 200 OK (or 503 if not ready yet).
// For now, just verify the endpoint exists and returns a valid HTTP response.
Assert.True(
response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.ServiceUnavailable,
$"Expected 200 or 503 but got {response.StatusCode}");
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ScadaLink.Host/ScadaLink.Host.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Host.Actors;
namespace ScadaLink.IntegrationTests;
/// <summary>
/// Shared WebApplicationFactory for integration tests.
/// Replaces SQL Server with an in-memory database and skips migrations.
/// Removes AkkaHostedService to avoid DNS resolution issues in test environments.
/// Uses environment variables for config since Program.cs reads them in the initial ConfigurationBuilder
/// before WebApplicationFactory can inject settings.
/// </summary>
public class ScadaLinkWebApplicationFactory : WebApplicationFactory<Program>
{
/// <summary>
/// Environment variables that were set by this factory, to be cleaned up on dispose.
/// </summary>
private readonly Dictionary<string, string?> _previousEnvVars = new();
public ScadaLinkWebApplicationFactory()
{
// The initial ConfigurationBuilder in Program.cs reads env vars with AddEnvironmentVariables().
// The env var format uses __ as section separator.
var envVars = new Dictionary<string, string>
{
["DOTNET_ENVIRONMENT"] = "Development",
["ScadaLink__Node__Role"] = "Central",
["ScadaLink__Node__NodeHostname"] = "localhost",
["ScadaLink__Node__RemotingPort"] = "8081",
["ScadaLink__Cluster__SeedNodes__0"] = "akka.tcp://scadalink@localhost:8081",
["ScadaLink__Cluster__SeedNodes__1"] = "akka.tcp://scadalink@localhost:8082",
["ScadaLink__Database__ConfigurationDb"] = "Server=localhost;Database=ScadaLink_Test;TrustServerCertificate=True",
["ScadaLink__Database__MachineDataDb"] = "Server=localhost;Database=ScadaLink_MachineData_Test;TrustServerCertificate=True",
["ScadaLink__Database__SkipMigrations"] = "true",
["ScadaLink__Security__JwtSigningKey"] = "integration-test-signing-key-must-be-at-least-32-chars-long",
["ScadaLink__Security__LdapServer"] = "localhost",
["ScadaLink__Security__LdapPort"] = "3893",
["ScadaLink__Security__LdapUseTls"] = "false",
["ScadaLink__Security__AllowInsecureLdap"] = "true",
["ScadaLink__Security__LdapSearchBase"] = "dc=scadalink,dc=local",
};
foreach (var (key, value) in envVars)
{
_previousEnvVars[key] = Environment.GetEnvironmentVariable(key);
Environment.SetEnvironmentVariable(key, value);
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureServices(services =>
{
// Remove ALL DbContext and EF-related service registrations to avoid dual-provider conflict.
// AddDbContext<> with UseSqlServer registers many internal services. We must remove them all.
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
d.ServiceType == typeof(DbContextOptions) ||
d.ServiceType == typeof(ScadaLinkDbContext) ||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
.ToList();
foreach (var d in descriptorsToRemove)
services.Remove(d);
// Add in-memory database as sole provider
services.AddDbContext<ScadaLinkDbContext>(options =>
options.UseInMemoryDatabase($"ScadaLink_IntegrationTests_{Guid.NewGuid()}"));
// Remove AkkaHostedService to avoid Akka.NET remoting DNS resolution in tests.
// It registers as both a singleton and a hosted service via factory.
var akkaDescriptors = services
.Where(d =>
d.ServiceType == typeof(AkkaHostedService) ||
(d.ServiceType == typeof(IHostedService) && d.ImplementationFactory != null))
.ToList();
foreach (var d in akkaDescriptors)
services.Remove(d);
});
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (disposing)
{
foreach (var (key, previousValue) in _previousEnvVars)
{
Environment.SetEnvironmentVariable(key, previousValue);
}
}
}
}

View File

@@ -0,0 +1,128 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
namespace ScadaLink.IntegrationTests;
/// <summary>
/// WP-22: Startup validation — missing required config fails with clear error.
/// Tests the StartupValidator that runs on boot.
///
/// Note: These tests temporarily set environment variables because Program.cs reads
/// configuration from env vars in the initial ConfigurationBuilder (before WebApplicationFactory
/// can inject settings). Each test saves/restores env vars to avoid interference.
/// </summary>
public class StartupValidationTests
{
[Fact]
public void MissingRole_ThrowsInvalidOperationException()
{
// Set all required config EXCEPT Role
using var env = new TempEnvironment(new Dictionary<string, string>
{
["DOTNET_ENVIRONMENT"] = "Development",
["ScadaLink__Node__NodeHostname"] = "localhost",
["ScadaLink__Node__RemotingPort"] = "8081",
["ScadaLink__Cluster__SeedNodes__0"] = "akka.tcp://scadalink@localhost:8081",
["ScadaLink__Cluster__SeedNodes__1"] = "akka.tcp://scadalink@localhost:8082",
});
var factory = new WebApplicationFactory<Program>();
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateClient());
Assert.Contains("Role", ex.Message, StringComparison.OrdinalIgnoreCase);
factory.Dispose();
}
[Fact]
public void MissingJwtSigningKey_ForCentral_ThrowsInvalidOperationException()
{
using var env = new TempEnvironment(new Dictionary<string, string>
{
["DOTNET_ENVIRONMENT"] = "Development",
["ScadaLink__Node__Role"] = "Central",
["ScadaLink__Node__NodeHostname"] = "localhost",
["ScadaLink__Node__RemotingPort"] = "8081",
["ScadaLink__Cluster__SeedNodes__0"] = "akka.tcp://scadalink@localhost:8081",
["ScadaLink__Cluster__SeedNodes__1"] = "akka.tcp://scadalink@localhost:8082",
["ScadaLink__Database__ConfigurationDb"] = "Server=x;Database=x",
["ScadaLink__Database__MachineDataDb"] = "Server=x;Database=x",
["ScadaLink__Security__LdapServer"] = "localhost",
// Deliberately missing JwtSigningKey
});
var factory = new WebApplicationFactory<Program>();
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateClient());
Assert.Contains("JwtSigningKey", ex.Message, StringComparison.OrdinalIgnoreCase);
factory.Dispose();
}
[Fact]
public void CentralRole_StartsSuccessfully_WithValidConfig()
{
using var factory = new ScadaLinkWebApplicationFactory();
using var client = factory.CreateClient();
Assert.NotNull(client);
}
/// <summary>
/// Helper to temporarily set environment variables and restore them on dispose.
/// Clears all ScadaLink__ vars first to ensure a clean slate.
/// </summary>
private sealed class TempEnvironment : IDisposable
{
private readonly Dictionary<string, string?> _previousValues = new();
/// <summary>
/// All ScadaLink env vars that might be set by other tests/factories.
/// </summary>
private static readonly string[] KnownKeys =
{
"DOTNET_ENVIRONMENT",
"ScadaLink__Node__Role",
"ScadaLink__Node__NodeHostname",
"ScadaLink__Node__RemotingPort",
"ScadaLink__Node__SiteId",
"ScadaLink__Cluster__SeedNodes__0",
"ScadaLink__Cluster__SeedNodes__1",
"ScadaLink__Database__ConfigurationDb",
"ScadaLink__Database__MachineDataDb",
"ScadaLink__Database__SkipMigrations",
"ScadaLink__Security__JwtSigningKey",
"ScadaLink__Security__LdapServer",
"ScadaLink__Security__LdapPort",
"ScadaLink__Security__LdapUseTls",
"ScadaLink__Security__AllowInsecureLdap",
"ScadaLink__Security__LdapSearchBase",
};
public TempEnvironment(Dictionary<string, string> varsToSet)
{
// Save and clear all known keys
foreach (var key in KnownKeys)
{
_previousValues[key] = Environment.GetEnvironmentVariable(key);
Environment.SetEnvironmentVariable(key, null);
}
// Set the requested vars
foreach (var (key, value) in varsToSet)
{
if (!_previousValues.ContainsKey(key))
_previousValues[key] = Environment.GetEnvironmentVariable(key);
Environment.SetEnvironmentVariable(key, value);
}
}
public void Dispose()
{
foreach (var (key, previousValue) in _previousValues)
{
Environment.SetEnvironmentVariable(key, previousValue);
}
}
}
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeAssembly": false,
"parallelizeTestCollections": false
}