diff --git a/ScadaLink.slnx b/ScadaLink.slnx index f387d4d..75866dd 100644 --- a/ScadaLink.slnx +++ b/ScadaLink.slnx @@ -36,5 +36,6 @@ + diff --git a/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs new file mode 100644 index 0000000..319cf55 --- /dev/null +++ b/src/ScadaLink.CentralUI/Auth/AuthEndpoints.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.Security; + +namespace ScadaLink.CentralUI.Auth; + +/// +/// Minimal API endpoints for login/logout. These run outside Blazor Server (standard HTTP POST). +/// On success, sets an HTTP-only cookie containing the JWT, then redirects to dashboard. +/// +public static class AuthEndpoints +{ + public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost("/auth/login", async (HttpContext context) => + { + var form = await context.Request.ReadFormAsync(); + var username = form["username"].ToString(); + var password = form["password"].ToString(); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + context.Response.Redirect("/login?error=Username+and+password+are+required."); + return; + } + + var ldapAuth = context.RequestServices.GetRequiredService(); + var jwtService = context.RequestServices.GetRequiredService(); + var roleMapper = context.RequestServices.GetRequiredService(); + + var authResult = await ldapAuth.AuthenticateAsync(username, password); + if (!authResult.Success) + { + var errorMsg = Uri.EscapeDataString(authResult.ErrorMessage ?? "Authentication failed."); + context.Response.Redirect($"/login?error={errorMsg}"); + return; + } + + // Map LDAP groups to roles + var roleMappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups ?? []); + + var token = jwtService.GenerateToken( + authResult.DisplayName ?? username, + authResult.Username ?? username, + roleMappingResult.Roles, + roleMappingResult.IsSystemWideDeployment ? null : roleMappingResult.PermittedSiteIds); + + // Set HTTP-only cookie with the JWT + context.Response.Cookies.Append( + CookieAuthenticationStateProvider.AuthCookieName, + token, + new CookieOptions + { + HttpOnly = true, + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = "/", + // Cookie expiry matches JWT idle timeout (30 min default) + MaxAge = TimeSpan.FromMinutes(30) + }); + + context.Response.Redirect("/"); + }); + + endpoints.MapPost("/auth/logout", (HttpContext context) => + { + context.Response.Cookies.Delete(CookieAuthenticationStateProvider.AuthCookieName, new CookieOptions + { + Path = "/" + }); + context.Response.Redirect("/login"); + }); + + return endpoints; + } +} diff --git a/src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs b/src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs new file mode 100644 index 0000000..d0b44c3 --- /dev/null +++ b/src/ScadaLink.CentralUI/Auth/CookieAuthenticationStateProvider.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; +using Microsoft.AspNetCore.Http; +using ScadaLink.Security; + +namespace ScadaLink.CentralUI.Auth; + +/// +/// Reads the JWT from an HTTP-only cookie and creates a ClaimsPrincipal for Blazor Server. +/// This bridges cookie-based auth (set by the login endpoint) with Blazor's auth state. +/// +public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider +{ + public const string AuthCookieName = "ScadaLink.Auth"; + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly JwtTokenService _jwtTokenService; + + public CookieAuthenticationStateProvider( + IHttpContextAccessor httpContextAccessor, + JwtTokenService jwtTokenService) + { + _httpContextAccessor = httpContextAccessor; + _jwtTokenService = jwtTokenService; + } + + public override Task GetAuthenticationStateAsync() + { + var httpContext = _httpContextAccessor.HttpContext; + if (httpContext == null) + { + return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); + } + + var token = httpContext.Request.Cookies[AuthCookieName]; + if (string.IsNullOrEmpty(token)) + { + return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); + } + + var principal = _jwtTokenService.ValidateToken(token); + if (principal == null) + { + return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); + } + + // Check idle timeout + if (_jwtTokenService.IsIdleTimedOut(principal)) + { + return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); + } + + return Task.FromResult(new AuthenticationState(principal)); + } +} diff --git a/src/ScadaLink.CentralUI/Components/App.razor b/src/ScadaLink.CentralUI/Components/App.razor new file mode 100644 index 0000000..416a4b8 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/App.razor @@ -0,0 +1,131 @@ + + + + + + ScadaLink + + + + + + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + } + else + { + + } + + +

Checking authorization...

+
+
+
+ + +
+

Page Not Found

+

The requested page does not exist.

+ Return to Dashboard +
+
+
+
+
+ +
+ +
+ + + + + + diff --git a/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..0ebafe1 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Layout/MainLayout.razor @@ -0,0 +1,8 @@ +@inherits LayoutComponentBase + +
+ +
+ @Body +
+
diff --git a/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..165f443 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor @@ -0,0 +1,80 @@ +@using ScadaLink.Security + + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor new file mode 100644 index 0000000..47540d9 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/Areas.razor @@ -0,0 +1,8 @@ +@page "/admin/areas" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] + +
+

Areas

+

Area management will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor new file mode 100644 index 0000000..7c81445 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/LdapMappings.razor @@ -0,0 +1,313 @@ +@page "/admin/ldap-mappings" +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] +@using ScadaLink.Commons.Entities.Security +@using ScadaLink.Commons.Interfaces.Repositories +@using ScadaLink.Security +@inject ISecurityRepository SecurityRepository + +
+
+

LDAP Group Mappings

+ +
+ + @if (_loading) + { +

Loading...

+ } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { + @* Add / Edit form *@ + @if (_showForm) + { +
+
+
@(_editingMapping == null ? "Add New Mapping" : "Edit Mapping")
+
+
+ + +
+
+ + +
+
+ + +
+
+ @if (_formError != null) + { +
@_formError
+ } +
+
+ } + + @* Mappings table *@ + + + + + + + + + + + + @if (_mappings.Count == 0) + { + + + + } + @foreach (var mapping in _mappings) + { + + + + + + + + } + +
IDLDAP Group NameRoleSite Scope RulesActions
No mappings configured.
@mapping.Id@mapping.LdapGroupName@mapping.Role + @{ + var rules = _scopeRules.GetValueOrDefault(mapping.Id); + } + @if (rules != null && rules.Count > 0) + { + @foreach (var rule in rules) + { + Site @rule.SiteId + } + } + else + { + All sites + } + @if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase)) + { + + } + + + +
+ + @* Scope rule form *@ + @if (_showScopeRuleForm) + { +
+
+
Add Site Scope Rule (Mapping #@_scopeRuleMappingId)
+
+
+ + +
+
+ + +
+
+ @if (_scopeRuleError != null) + { +
@_scopeRuleError
+ } +
+
+ } + } +
+ +@code { + private List _mappings = new(); + private Dictionary> _scopeRules = new(); + private bool _loading = true; + private string? _errorMessage; + + // Mapping form state + private bool _showForm; + private LdapGroupMapping? _editingMapping; + private string _formGroupName = string.Empty; + private string _formRole = string.Empty; + private string? _formError; + + // Scope rule form state + private bool _showScopeRuleForm; + private int _scopeRuleMappingId; + private int _scopeRuleSiteId; + private string? _scopeRuleError; + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + _loading = true; + _errorMessage = null; + try + { + _mappings = (await SecurityRepository.GetAllMappingsAsync()).ToList(); + _scopeRules.Clear(); + foreach (var mapping in _mappings) + { + var rules = await SecurityRepository.GetScopeRulesForMappingAsync(mapping.Id); + if (rules.Count > 0) + { + _scopeRules[mapping.Id] = rules.ToList(); + } + } + } + catch (Exception ex) + { + _errorMessage = $"Failed to load mappings: {ex.Message}"; + } + _loading = false; + } + + private void ShowAddForm() + { + _editingMapping = null; + _formGroupName = string.Empty; + _formRole = string.Empty; + _formError = null; + _showForm = true; + } + + private void EditMapping(LdapGroupMapping mapping) + { + _editingMapping = mapping; + _formGroupName = mapping.LdapGroupName; + _formRole = mapping.Role; + _formError = null; + _showForm = true; + } + + private void CancelForm() + { + _showForm = false; + _editingMapping = null; + _formError = null; + } + + private async Task SaveMapping() + { + _formError = null; + + if (string.IsNullOrWhiteSpace(_formGroupName)) + { + _formError = "LDAP Group Name is required."; + return; + } + if (string.IsNullOrWhiteSpace(_formRole)) + { + _formError = "Role is required."; + return; + } + + try + { + if (_editingMapping != null) + { + _editingMapping.LdapGroupName = _formGroupName.Trim(); + _editingMapping.Role = _formRole; + await SecurityRepository.UpdateMappingAsync(_editingMapping); + } + else + { + var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole); + await SecurityRepository.AddMappingAsync(mapping); + } + + await SecurityRepository.SaveChangesAsync(); + _showForm = false; + _editingMapping = null; + await LoadDataAsync(); + } + catch (Exception ex) + { + _formError = $"Save failed: {ex.Message}"; + } + } + + private async Task DeleteMapping(int id) + { + try + { + // Also delete scope rules for this mapping + var rules = await SecurityRepository.GetScopeRulesForMappingAsync(id); + foreach (var rule in rules) + { + await SecurityRepository.DeleteScopeRuleAsync(rule.Id); + } + await SecurityRepository.DeleteMappingAsync(id); + await SecurityRepository.SaveChangesAsync(); + await LoadDataAsync(); + } + catch (Exception ex) + { + _errorMessage = $"Delete failed: {ex.Message}"; + } + } + + private void ShowScopeRuleForm(int mappingId) + { + _scopeRuleMappingId = mappingId; + _scopeRuleSiteId = 0; + _scopeRuleError = null; + _showScopeRuleForm = true; + } + + private void CancelScopeRuleForm() + { + _showScopeRuleForm = false; + _scopeRuleError = null; + } + + private async Task SaveScopeRule() + { + _scopeRuleError = null; + + if (_scopeRuleSiteId <= 0) + { + _scopeRuleError = "Site ID must be a positive number."; + return; + } + + try + { + var rule = new SiteScopeRule + { + LdapGroupMappingId = _scopeRuleMappingId, + SiteId = _scopeRuleSiteId + }; + await SecurityRepository.AddScopeRuleAsync(rule); + await SecurityRepository.SaveChangesAsync(); + _showScopeRuleForm = false; + await LoadDataAsync(); + } + catch (Exception ex) + { + _scopeRuleError = $"Save failed: {ex.Message}"; + } + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor new file mode 100644 index 0000000..69af4d3 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/Sites.razor @@ -0,0 +1,8 @@ +@page "/admin/sites" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)] + +
+

Sites

+

Site management will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..1f2ba8f --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Dashboard.razor @@ -0,0 +1,33 @@ +@page "/" +@attribute [Authorize] + +
+

Welcome to ScadaLink

+

Central management console for the ScadaLink SCADA system.

+ + + +
+
+
Signed in as
+

@context.User.FindFirst("DisplayName")?.Value

+

@context.User.FindFirst("Username")?.Value

+ + @{ + var roles = context.User.FindAll("Role").Select(c => c.Value).ToList(); + } + @if (roles.Count > 0) + { +
Roles
+
+ @foreach (var role in roles) + { + @role + } +
+ } +
+
+
+
+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor new file mode 100644 index 0000000..d13984a --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/DebugView.razor @@ -0,0 +1,11 @@ +@page "/deployment/debug-view" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] + +
+

Debug View

+

Real-time debug view will be available in a future phase.

+ +
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor new file mode 100644 index 0000000..0de2cc7 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Deployments.razor @@ -0,0 +1,8 @@ +@page "/deployment/deployments" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] + +
+

Deployments

+

Deployment management will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor new file mode 100644 index 0000000..8524996 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Deployment/Instances.razor @@ -0,0 +1,8 @@ +@page "/deployment/instances" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] + +
+

Instances

+

Instance management will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor new file mode 100644 index 0000000..4c83684 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor @@ -0,0 +1,8 @@ +@page "/design/external-systems" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] + +
+

External Systems

+

External system management will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor new file mode 100644 index 0000000..491cfb8 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor @@ -0,0 +1,8 @@ +@page "/design/shared-scripts" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] + +
+

Shared Scripts

+

Shared script management will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor new file mode 100644 index 0000000..c358e81 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor @@ -0,0 +1,8 @@ +@page "/design/templates" +@using ScadaLink.Security +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] + +
+

Templates

+

Template management will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Login.razor b/src/ScadaLink.CentralUI/Components/Pages/Login.razor new file mode 100644 index 0000000..8a266ad --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Login.razor @@ -0,0 +1,34 @@ +@page "/login" + +
+
+
+

ScadaLink

+ + @if (!string.IsNullOrEmpty(ErrorMessage)) + { + + } + +
+
+ + +
+
+ + +
+ +
+
+
+

Authenticate with your organization's LDAP credentials.

+
+ +@code { + [SupplyParameterFromQuery(Name = "error")] + public string? ErrorMessage { get; set; } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor new file mode 100644 index 0000000..a9b7f64 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Monitoring/Health.razor @@ -0,0 +1,7 @@ +@page "/monitoring/health" +@attribute [Authorize] + +
+

Health Dashboard

+

Site health monitoring will be available in a future phase.

+
diff --git a/src/ScadaLink.CentralUI/Components/Shared/NotAuthorizedView.razor b/src/ScadaLink.CentralUI/Components/Shared/NotAuthorizedView.razor new file mode 100644 index 0000000..60cbc2d --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/NotAuthorizedView.razor @@ -0,0 +1,7 @@ +
+ + Return to Dashboard +
diff --git a/src/ScadaLink.CentralUI/Components/Shared/RedirectToLogin.razor b/src/ScadaLink.CentralUI/Components/Shared/RedirectToLogin.razor new file mode 100644 index 0000000..ca5b229 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/RedirectToLogin.razor @@ -0,0 +1,8 @@ +@inject NavigationManager Navigation + +@code { + protected override void OnInitialized() + { + Navigation.NavigateTo("/login", forceLoad: true); + } +} diff --git a/src/ScadaLink.CentralUI/EndpointExtensions.cs b/src/ScadaLink.CentralUI/EndpointExtensions.cs index 338fe5a..a231d8d 100644 --- a/src/ScadaLink.CentralUI/EndpointExtensions.cs +++ b/src/ScadaLink.CentralUI/EndpointExtensions.cs @@ -1,4 +1,7 @@ +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using ScadaLink.CentralUI.Auth; +using ScadaLink.CentralUI.Components; namespace ScadaLink.CentralUI; @@ -6,7 +9,11 @@ public static class EndpointExtensions { public static IEndpointRouteBuilder MapCentralUI(this IEndpointRouteBuilder endpoints) { - // Phase 0: skeleton only + endpoints.MapAuthEndpoints(); + + endpoints.MapRazorComponents() + .AddInteractiveServerRenderMode(); + return endpoints; } } diff --git a/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj b/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj index 3a1b728..f0272a8 100644 --- a/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj +++ b/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -13,6 +13,7 @@ + diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs index d6f9654..aeb91e3 100644 --- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Auth; namespace ScadaLink.CentralUI; @@ -6,7 +8,14 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddCentralUI(this IServiceCollection services) { - // Phase 0: skeleton only + services.AddRazorComponents() + .AddInteractiveServerComponents(); + + services.AddHttpContextAccessor(); + + services.AddScoped(); + services.AddCascadingAuthenticationState(); + return services; } } diff --git a/src/ScadaLink.CentralUI/_Imports.razor b/src/ScadaLink.CentralUI/_Imports.razor new file mode 100644 index 0000000..7c5491f --- /dev/null +++ b/src/ScadaLink.CentralUI/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using ScadaLink.CentralUI +@using ScadaLink.CentralUI.Components.Layout +@using ScadaLink.CentralUI.Components.Shared diff --git a/src/ScadaLink.Host/Actors/AkkaHostedService.cs b/src/ScadaLink.Host/Actors/AkkaHostedService.cs new file mode 100644 index 0000000..dea9239 --- /dev/null +++ b/src/ScadaLink.Host/Actors/AkkaHostedService.cs @@ -0,0 +1,106 @@ +using Akka.Actor; +using Akka.Configuration; +using Microsoft.Extensions.Options; +using ScadaLink.ClusterInfrastructure; +using ScadaLink.Host.Actors; + +namespace ScadaLink.Host.Actors; + +/// +/// Hosted service that manages the Akka.NET actor system lifecycle. +/// Creates the actor system on start, registers actors, and triggers +/// CoordinatedShutdown on stop. +/// +public class AkkaHostedService : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly NodeOptions _nodeOptions; + private readonly ClusterOptions _clusterOptions; + private readonly ILogger _logger; + private ActorSystem? _actorSystem; + + public AkkaHostedService( + IServiceProvider serviceProvider, + IOptions nodeOptions, + IOptions clusterOptions, + ILogger logger) + { + _serviceProvider = serviceProvider; + _nodeOptions = nodeOptions.Value; + _clusterOptions = clusterOptions.Value; + _logger = logger; + } + + /// + /// Gets the actor system once started. Null before StartAsync completes. + /// + public ActorSystem? ActorSystem => _actorSystem; + + public Task StartAsync(CancellationToken cancellationToken) + { + var seedNodesStr = string.Join(",", + _clusterOptions.SeedNodes.Select(s => $"\"{s}\"")); + + var hocon = $@" +akka {{ + actor {{ + provider = cluster + }} + remote {{ + dot-netty.tcp {{ + hostname = ""{_nodeOptions.NodeHostname}"" + port = {_nodeOptions.RemotingPort} + }} + }} + cluster {{ + seed-nodes = [{seedNodesStr}] + roles = [""{_nodeOptions.Role}""] + min-nr-of-members = {_clusterOptions.MinNrOfMembers} + split-brain-resolver {{ + active-strategy = {_clusterOptions.SplitBrainResolverStrategy} + stable-after = {_clusterOptions.StableAfter.TotalSeconds:F0}s + keep-oldest {{ + down-if-alone = on + }} + }} + failure-detector {{ + heartbeat-interval = {_clusterOptions.HeartbeatInterval.TotalSeconds:F0}s + acceptable-heartbeat-pause = {_clusterOptions.FailureDetectionThreshold.TotalSeconds:F0}s + }} + run-coordinated-shutdown-when-down = on + }} + coordinated-shutdown {{ + run-by-clr-shutdown-hook = on + }} +}}"; + + var config = ConfigurationFactory.ParseString(hocon); + _actorSystem = ActorSystem.Create("scadalink", config); + + _logger.LogInformation( + "Akka.NET actor system 'scadalink' started. Role={Role}, Hostname={Hostname}, Port={Port}", + _nodeOptions.Role, + _nodeOptions.NodeHostname, + _nodeOptions.RemotingPort); + + // Register the dead letter monitor actor + var loggerFactory = _serviceProvider.GetRequiredService(); + var dlmLogger = loggerFactory.CreateLogger(); + _actorSystem.ActorOf( + Props.Create(() => new DeadLetterMonitorActor(dlmLogger)), + "dead-letter-monitor"); + + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_actorSystem != null) + { + _logger.LogInformation("Shutting down Akka.NET actor system via CoordinatedShutdown..."); + var shutdown = Akka.Actor.CoordinatedShutdown.Get(_actorSystem); + await shutdown.Run(Akka.Actor.CoordinatedShutdown.ClrExitReason.Instance); + _logger.LogInformation("Akka.NET actor system shutdown complete."); + } + } +} diff --git a/src/ScadaLink.Host/Actors/DeadLetterMonitorActor.cs b/src/ScadaLink.Host/Actors/DeadLetterMonitorActor.cs new file mode 100644 index 0000000..1301ee5 --- /dev/null +++ b/src/ScadaLink.Host/Actors/DeadLetterMonitorActor.cs @@ -0,0 +1,53 @@ +using Akka.Actor; +using Akka.Event; +using Microsoft.Extensions.Logging; + +namespace ScadaLink.Host.Actors; + +/// +/// Subscribes to Akka.NET dead letter events, logs them, and tracks count +/// for health monitoring integration. +/// +public class DeadLetterMonitorActor : ReceiveActor +{ + private long _deadLetterCount; + + public DeadLetterMonitorActor(ILogger logger) + { + Receive(dl => + { + _deadLetterCount++; + logger.LogWarning( + "Dead letter: {MessageType} from {Sender} to {Recipient}", + dl.Message.GetType().Name, + dl.Sender, + dl.Recipient); + }); + + Receive(_ => Sender.Tell(new DeadLetterCountResponse(_deadLetterCount))); + } + + protected override void PreStart() + { + Context.System.EventStream.Subscribe(Self, typeof(DeadLetter)); + } + + protected override void PostStop() + { + Context.System.EventStream.Unsubscribe(Self, typeof(DeadLetter)); + } +} + +/// +/// Message to request the current dead letter count. +/// +public sealed class GetDeadLetterCount +{ + public static readonly GetDeadLetterCount Instance = new(); + private GetDeadLetterCount() { } +} + +/// +/// Response containing the current dead letter count. +/// +public sealed record DeadLetterCountResponse(long Count); diff --git a/src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs b/src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs new file mode 100644 index 0000000..0e2577d --- /dev/null +++ b/src/ScadaLink.Host/Health/AkkaClusterHealthCheck.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ScadaLink.Host.Health; + +/// +/// Health check that verifies Akka.NET cluster membership. +/// Initially returns healthy; will be refined when Akka cluster integration is complete. +/// +public class AkkaClusterHealthCheck : IHealthCheck +{ + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + // TODO: Query Akka Cluster.Get(system).State to verify this node is Up. + // For now, return healthy as Akka cluster wiring is being established. + return Task.FromResult(HealthCheckResult.Healthy("Akka cluster health check placeholder.")); + } +} diff --git a/src/ScadaLink.Host/Health/DatabaseHealthCheck.cs b/src/ScadaLink.Host/Health/DatabaseHealthCheck.cs new file mode 100644 index 0000000..1dd336a --- /dev/null +++ b/src/ScadaLink.Host/Health/DatabaseHealthCheck.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ScadaLink.ConfigurationDatabase; + +namespace ScadaLink.Host.Health; + +/// +/// Health check that verifies database connectivity for Central nodes. +/// +public class DatabaseHealthCheck : IHealthCheck +{ + private readonly ScadaLinkDbContext _dbContext; + + public DatabaseHealthCheck(ScadaLinkDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken); + return canConnect + ? HealthCheckResult.Healthy("Database connection is available.") + : HealthCheckResult.Unhealthy("Database connection failed."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("Database connection failed.", ex); + } + } +} diff --git a/src/ScadaLink.Host/Program.cs b/src/ScadaLink.Host/Program.cs index 5690573..1f6b42d 100644 --- a/src/ScadaLink.Host/Program.cs +++ b/src/ScadaLink.Host/Program.cs @@ -1,3 +1,5 @@ +using HealthChecks.UI.Client; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using ScadaLink.CentralUI; using ScadaLink.ClusterInfrastructure; using ScadaLink.Communication; @@ -7,6 +9,8 @@ using ScadaLink.DeploymentManager; using ScadaLink.ExternalSystemGateway; using ScadaLink.HealthMonitoring; using ScadaLink.Host; +using ScadaLink.Host.Actors; +using ScadaLink.Host.Health; using ScadaLink.InboundAPI; using ScadaLink.NotificationService; using ScadaLink.Security; @@ -14,6 +18,7 @@ using ScadaLink.SiteEventLogging; using ScadaLink.SiteRuntime; using ScadaLink.StoreAndForward; using ScadaLink.TemplateEngine; +using Serilog; var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: false) @@ -22,86 +27,148 @@ var configuration = new ConfigurationBuilder() .AddCommandLine(args) .Build(); -var role = configuration["ScadaLink:Node:Role"] - ?? throw new InvalidOperationException("ScadaLink:Node:Role is required"); +// WP-11: Full startup validation — fail fast before any DI or actor system setup +StartupValidator.Validate(configuration); -if (role.Equals("Central", StringComparison.OrdinalIgnoreCase)) +// Read node options for Serilog enrichment +var nodeRole = configuration["ScadaLink:Node:Role"]!; +var nodeHostname = configuration["ScadaLink:Node:NodeHostname"] ?? "unknown"; +var siteId = configuration["ScadaLink:Node:SiteId"] ?? "central"; + +// WP-14: Serilog structured logging +Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(configuration) + .Enrich.WithProperty("SiteId", siteId) + .Enrich.WithProperty("NodeHostname", nodeHostname) + .Enrich.WithProperty("NodeRole", nodeRole) + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] [{NodeRole}/{NodeHostname}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File("logs/scadalink-.log", rollingInterval: Serilog.RollingInterval.Day) + .CreateLogger(); + +try { - var builder = WebApplication.CreateBuilder(args); - builder.Configuration.AddConfiguration(configuration); + Log.Information("Starting ScadaLink host as {Role} on {Hostname}", nodeRole, nodeHostname); - // Shared components - builder.Services.AddClusterInfrastructure(); - builder.Services.AddCommunication(); - builder.Services.AddHealthMonitoring(); - builder.Services.AddExternalSystemGateway(); - builder.Services.AddNotificationService(); - - // Central-only components - builder.Services.AddTemplateEngine(); - builder.Services.AddDeploymentManager(); - builder.Services.AddSecurity(); - builder.Services.AddCentralUI(); - builder.Services.AddInboundAPI(); - - var configDbConnectionString = configuration["ScadaLink:Database:ConfigurationDb"] - ?? throw new InvalidOperationException("ScadaLink:Database:ConfigurationDb connection string is required for Central role."); - builder.Services.AddConfigurationDatabase(configDbConnectionString); - - // Options binding - BindSharedOptions(builder.Services, builder.Configuration); - builder.Services.Configure(builder.Configuration.GetSection("ScadaLink:Security")); - builder.Services.Configure(builder.Configuration.GetSection("ScadaLink:InboundApi")); - - var app = builder.Build(); - - // Apply or validate database migrations (skip when running in test harness) - if (!string.Equals(configuration["ScadaLink:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase)) + if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase)) { - var isDevelopment = app.Environment.IsDevelopment(); - using (var scope = app.Services.CreateScope()) - { - var dbContext = scope.ServiceProvider.GetRequiredService(); - await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment); - } - } + var builder = WebApplication.CreateBuilder(args); + builder.Configuration.AddConfiguration(configuration); + + // WP-14: Serilog + builder.Host.UseSerilog(); + + // WP-17: Windows Service support (no-op when not running as a Windows Service) + builder.Host.UseWindowsService(); - app.MapCentralUI(); - app.MapInboundAPI(); - await app.RunAsync(); -} -else if (role.Equals("Site", StringComparison.OrdinalIgnoreCase)) -{ - var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args); - builder.ConfigureAppConfiguration(config => config.AddConfiguration(configuration)); - builder.ConfigureServices((context, services) => - { // Shared components - services.AddClusterInfrastructure(); - services.AddCommunication(); - services.AddHealthMonitoring(); - services.AddExternalSystemGateway(); - services.AddNotificationService(); + builder.Services.AddClusterInfrastructure(); + builder.Services.AddCommunication(); + builder.Services.AddHealthMonitoring(); + builder.Services.AddExternalSystemGateway(); + builder.Services.AddNotificationService(); - // Site-only components - services.AddSiteRuntime(); - services.AddDataConnectionLayer(); - services.AddStoreAndForward(); - services.AddSiteEventLogging(); + // Central-only components + builder.Services.AddTemplateEngine(); + builder.Services.AddDeploymentManager(); + builder.Services.AddSecurity(); + builder.Services.AddCentralUI(); + builder.Services.AddInboundAPI(); + + var configDbConnectionString = configuration["ScadaLink:Database:ConfigurationDb"] + ?? throw new InvalidOperationException("ScadaLink:Database:ConfigurationDb connection string is required for Central role."); + builder.Services.AddConfigurationDatabase(configDbConnectionString); + + // WP-12: Health checks for readiness gating + builder.Services.AddHealthChecks() + .AddCheck("database") + .AddCheck("akka-cluster"); + + // WP-13: Akka.NET bootstrap via hosted service + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Options binding - BindSharedOptions(services, context.Configuration); - services.Configure(context.Configuration.GetSection("ScadaLink:DataConnection")); - services.Configure(context.Configuration.GetSection("ScadaLink:StoreAndForward")); - services.Configure(context.Configuration.GetSection("ScadaLink:SiteEventLog")); - }); + BindSharedOptions(builder.Services, builder.Configuration); + builder.Services.Configure(builder.Configuration.GetSection("ScadaLink:Security")); + builder.Services.Configure(builder.Configuration.GetSection("ScadaLink:InboundApi")); - var host = builder.Build(); - await host.RunAsync(); + var app = builder.Build(); + + // Apply or validate database migrations (skip when running in test harness) + if (!string.Equals(configuration["ScadaLink:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase)) + { + var isDevelopment = app.Environment.IsDevelopment(); + using (var scope = app.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + await MigrationHelper.ApplyOrValidateMigrationsAsync(dbContext, isDevelopment); + } + } + + // WP-12: Map readiness endpoint — returns 503 until all checks pass, 200 when ready + app.MapHealthChecks("/health/ready", new HealthCheckOptions + { + ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse + }); + + app.MapCentralUI(); + app.MapInboundAPI(); + await app.RunAsync(); + } + else if (nodeRole.Equals("Site", StringComparison.OrdinalIgnoreCase)) + { + var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args); + builder.ConfigureAppConfiguration(config => config.AddConfiguration(configuration)); + + // WP-14: Serilog + builder.UseSerilog(); + + // WP-17: Windows Service support (no-op when not running as a Windows Service) + builder.UseWindowsService(); + + builder.ConfigureServices((context, services) => + { + // Shared components + services.AddClusterInfrastructure(); + services.AddCommunication(); + services.AddHealthMonitoring(); + services.AddExternalSystemGateway(); + services.AddNotificationService(); + + // Site-only components + services.AddSiteRuntime(); + services.AddDataConnectionLayer(); + services.AddStoreAndForward(); + services.AddSiteEventLogging(); + + // WP-13: Akka.NET bootstrap via hosted service + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + + // Options binding + BindSharedOptions(services, context.Configuration); + services.Configure(context.Configuration.GetSection("ScadaLink:DataConnection")); + services.Configure(context.Configuration.GetSection("ScadaLink:StoreAndForward")); + services.Configure(context.Configuration.GetSection("ScadaLink:SiteEventLog")); + }); + + var host = builder.Build(); + await host.RunAsync(); + } + else + { + throw new InvalidOperationException($"Unknown role: {nodeRole}. Must be 'Central' or 'Site'."); + } } -else +catch (Exception ex) { - throw new InvalidOperationException($"Unknown role: {role}. Must be 'Central' or 'Site'."); + Log.Fatal(ex, "ScadaLink host terminated unexpectedly"); + throw; +} +finally +{ + await Log.CloseAndFlushAsync(); } static void BindSharedOptions(IServiceCollection services, IConfiguration config) diff --git a/src/ScadaLink.Host/ScadaLink.Host.csproj b/src/ScadaLink.Host/ScadaLink.Host.csproj index 8a5f006..92b5f6e 100644 --- a/src/ScadaLink.Host/ScadaLink.Host.csproj +++ b/src/ScadaLink.Host/ScadaLink.Host.csproj @@ -8,10 +8,18 @@ + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all + + + + diff --git a/src/ScadaLink.Host/StartupValidator.cs b/src/ScadaLink.Host/StartupValidator.cs new file mode 100644 index 0000000..a33bc18 --- /dev/null +++ b/src/ScadaLink.Host/StartupValidator.cs @@ -0,0 +1,58 @@ +namespace ScadaLink.Host; + +/// +/// Validates required configuration before Akka.NET actor system creation. +/// Runs early in startup to fail fast with clear error messages. +/// +public static class StartupValidator +{ + public static void Validate(IConfiguration configuration) + { + var errors = new List(); + + var nodeSection = configuration.GetSection("ScadaLink:Node"); + var role = nodeSection["Role"]; + if (string.IsNullOrEmpty(role) || (role != "Central" && role != "Site")) + errors.Add("ScadaLink:Node:Role must be 'Central' or 'Site'"); + + if (string.IsNullOrEmpty(nodeSection["NodeHostname"])) + errors.Add("ScadaLink:Node:NodeHostname is required"); + + var portStr = nodeSection["RemotingPort"]; + if (!int.TryParse(portStr, out var port) || port < 1 || port > 65535) + errors.Add("ScadaLink:Node:RemotingPort must be 1-65535"); + + if (role == "Site" && string.IsNullOrEmpty(nodeSection["SiteId"])) + errors.Add("ScadaLink:Node:SiteId is required for Site nodes"); + + if (role == "Central") + { + var dbSection = configuration.GetSection("ScadaLink:Database"); + if (string.IsNullOrEmpty(dbSection["ConfigurationDb"])) + errors.Add("ScadaLink:Database:ConfigurationDb connection string required for Central"); + if (string.IsNullOrEmpty(dbSection["MachineDataDb"])) + errors.Add("ScadaLink:Database:MachineDataDb connection string required for Central"); + + var secSection = configuration.GetSection("ScadaLink:Security"); + if (string.IsNullOrEmpty(secSection["LdapServer"])) + errors.Add("ScadaLink:Security:LdapServer required for Central"); + if (string.IsNullOrEmpty(secSection["JwtSigningKey"])) + errors.Add("ScadaLink:Security:JwtSigningKey required for Central"); + } + + if (role == "Site") + { + var dbSection = configuration.GetSection("ScadaLink:Database"); + if (string.IsNullOrEmpty(dbSection["SiteDbPath"])) + errors.Add("ScadaLink:Database:SiteDbPath required for Site nodes"); + } + + var seedNodes = configuration.GetSection("ScadaLink:Cluster:SeedNodes").Get>(); + if (seedNodes == null || seedNodes.Count < 2) + errors.Add("ScadaLink:Cluster:SeedNodes must have at least 2 entries"); + + if (errors.Count > 0) + throw new InvalidOperationException( + $"Configuration validation failed:\n{string.Join("\n", errors.Select(e => $" - {e}"))}"); + } +} diff --git a/tests/ScadaLink.Host.Tests/AkkaBootstrapTests.cs b/tests/ScadaLink.Host.Tests/AkkaBootstrapTests.cs new file mode 100644 index 0000000..78df70c --- /dev/null +++ b/tests/ScadaLink.Host.Tests/AkkaBootstrapTests.cs @@ -0,0 +1,80 @@ +using Akka.Actor; +using Akka.Configuration; + +namespace ScadaLink.Host.Tests; + +/// +/// WP-13: Tests for Akka.NET actor system bootstrap. +/// +public class AkkaBootstrapTests : IDisposable +{ + private ActorSystem? _actorSystem; + + public void Dispose() + { + _actorSystem?.Dispose(); + } + + [Fact] + public void ActorSystem_CreatesWithClusterConfig() + { + var hocon = @" +akka { + actor { + provider = cluster + } + remote { + dot-netty.tcp { + hostname = ""localhost"" + port = 0 + } + } + cluster { + seed-nodes = [""akka.tcp://scadalink-test@localhost:0""] + roles = [""Central""] + min-nr-of-members = 1 + } + coordinated-shutdown { + run-by-clr-shutdown-hook = on + } +}"; + var config = ConfigurationFactory.ParseString(hocon); + _actorSystem = ActorSystem.Create("scadalink-test", config); + + Assert.NotNull(_actorSystem); + Assert.Equal("scadalink-test", _actorSystem.Name); + } + + [Fact] + public void ActorSystem_HoconConfig_IncludesCoordinatedShutdown() + { + var hocon = @" +akka { + actor { + provider = cluster + } + remote { + dot-netty.tcp { + hostname = ""localhost"" + port = 0 + } + } + cluster { + seed-nodes = [""akka.tcp://scadalink-test@localhost:0""] + roles = [""Central""] + run-coordinated-shutdown-when-down = on + } + coordinated-shutdown { + run-by-clr-shutdown-hook = on + } +}"; + var config = ConfigurationFactory.ParseString(hocon); + _actorSystem = ActorSystem.Create("scadalink-cs-test", config); + + var csConfig = _actorSystem.Settings.Config.GetString("akka.coordinated-shutdown.run-by-clr-shutdown-hook"); + Assert.Equal("on", csConfig); + + var clusterShutdown = _actorSystem.Settings.Config.GetString("akka.cluster.run-coordinated-shutdown-when-down"); + Assert.Equal("on", clusterShutdown); + } +} diff --git a/tests/ScadaLink.Host.Tests/CoordinatedShutdownTests.cs b/tests/ScadaLink.Host.Tests/CoordinatedShutdownTests.cs new file mode 100644 index 0000000..9c69680 --- /dev/null +++ b/tests/ScadaLink.Host.Tests/CoordinatedShutdownTests.cs @@ -0,0 +1,60 @@ +using System.Reflection; + +namespace ScadaLink.Host.Tests; + +/// +/// WP-16: Tests for CoordinatedShutdown configuration. +/// Verifies no Environment.Exit calls exist in source and HOCON config is correct. +/// +public class CoordinatedShutdownTests +{ + [Fact] + public void HostSource_DoesNotContainEnvironmentExit() + { + var hostProjectDir = FindHostProjectDirectory(); + Assert.NotNull(hostProjectDir); + + var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.AllDirectories); + Assert.NotEmpty(sourceFiles); + + foreach (var file in sourceFiles) + { + var content = File.ReadAllText(file); + Assert.DoesNotContain("Environment.Exit", content, + StringComparison.Ordinal); + } + } + + [Fact] + public void AkkaHostedService_HoconConfig_IncludesCoordinatedShutdownSettings() + { + // Read the AkkaHostedService source to verify HOCON configuration + var hostProjectDir = FindHostProjectDirectory(); + Assert.NotNull(hostProjectDir); + + var akkaServiceFile = Path.Combine(hostProjectDir, "Actors", "AkkaHostedService.cs"); + Assert.True(File.Exists(akkaServiceFile), $"AkkaHostedService.cs not found at {akkaServiceFile}"); + + var content = File.ReadAllText(akkaServiceFile); + + // Verify critical HOCON settings are present + Assert.Contains("run-by-clr-shutdown-hook = on", content); + Assert.Contains("run-coordinated-shutdown-when-down = on", content); + } + + 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; + } + + return null; + } +} diff --git a/tests/ScadaLink.Host.Tests/DeadLetterMonitorTests.cs b/tests/ScadaLink.Host.Tests/DeadLetterMonitorTests.cs new file mode 100644 index 0000000..9936b4c --- /dev/null +++ b/tests/ScadaLink.Host.Tests/DeadLetterMonitorTests.cs @@ -0,0 +1,74 @@ +using Akka.Actor; +using Akka.Event; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ScadaLink.Host.Actors; + +namespace ScadaLink.Host.Tests; + +/// +/// WP-15: Tests for DeadLetterMonitorActor. +/// +public class DeadLetterMonitorTests : TestKit +{ + private readonly ILogger _logger = + NullLoggerFactory.Instance.CreateLogger(); + + [Fact] + public void DeadLetterMonitor_StartsWithZeroCount() + { + var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger))); + + monitor.Tell(GetDeadLetterCount.Instance); + var response = ExpectMsg(); + + Assert.Equal(0, response.Count); + } + + [Fact] + public void DeadLetterMonitor_IncrementsOnDeadLetter() + { + var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger))); + + // Ensure actor has started and subscribed by sending a message and waiting for response + monitor.Tell(GetDeadLetterCount.Instance); + ExpectMsg(); + + // Now publish dead letters — actor is guaranteed to be subscribed + Sys.EventStream.Publish(new DeadLetter("test-message-1", Sys.DeadLetters, Sys.DeadLetters)); + Sys.EventStream.Publish(new DeadLetter("test-message-2", Sys.DeadLetters, Sys.DeadLetters)); + + // Use AwaitAssert to handle async event delivery + AwaitAssert(() => + { + monitor.Tell(GetDeadLetterCount.Instance); + var response = ExpectMsg(); + Assert.Equal(2, response.Count); + }); + } + + [Fact] + public void DeadLetterMonitor_CountAccumulates() + { + var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger))); + + // Ensure actor is started and subscribed + monitor.Tell(GetDeadLetterCount.Instance); + ExpectMsg(); + + // Send 5 dead letters + for (var i = 0; i < 5; i++) + { + Sys.EventStream.Publish( + new DeadLetter($"message-{i}", Sys.DeadLetters, Sys.DeadLetters)); + } + + AwaitAssert(() => + { + monitor.Tell(GetDeadLetterCount.Instance); + var response = ExpectMsg(); + Assert.Equal(5, response.Count); + }); + } +} diff --git a/tests/ScadaLink.Host.Tests/HealthCheckTests.cs b/tests/ScadaLink.Host.Tests/HealthCheckTests.cs new file mode 100644 index 0000000..e16aae7 --- /dev/null +++ b/tests/ScadaLink.Host.Tests/HealthCheckTests.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace ScadaLink.Host.Tests; + +/// +/// WP-12: Tests for /health/ready endpoint. +/// +public class HealthCheckTests : IDisposable +{ + private readonly List _disposables = new(); + + public void Dispose() + { + foreach (var d in _disposables) + { + try { d.Dispose(); } catch { /* best effort */ } + } + } + + [Fact] + public async Task HealthReady_Endpoint_ReturnsResponse() + { + var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + try + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); + + var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:NodeHostname"] = "localhost", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552", + ["ScadaLink:Database:SkipMigrations"] = "true", + }); + }); + builder.UseSetting("ScadaLink:Node:Role", "Central"); + builder.UseSetting("ScadaLink:Database:SkipMigrations", "true"); + }); + _disposables.Add(factory); + + var client = factory.CreateClient(); + _disposables.Add(client); + + var response = await client.GetAsync("/health/ready"); + + // The endpoint exists and returns a status code. + // With test infrastructure (no real DB), the database check may fail, + // so we accept either 200 (Healthy) or 503 (Unhealthy). + Assert.True( + response.StatusCode == System.Net.HttpStatusCode.OK || + response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable, + $"Expected 200 or 503, got {(int)response.StatusCode}"); + } + finally + { + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv); + } + } +} diff --git a/tests/ScadaLink.Host.Tests/HostStartupTests.cs b/tests/ScadaLink.Host.Tests/HostStartupTests.cs index fb156c6..0418cb8 100644 --- a/tests/ScadaLink.Host.Tests/HostStartupTests.cs +++ b/tests/ScadaLink.Host.Tests/HostStartupTests.cs @@ -42,6 +42,17 @@ public class HostStartupTests : IDisposable var factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ScadaLink:Node:NodeHostname"] = "localhost", + ["ScadaLink:Node:RemotingPort"] = "0", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552", + ["ScadaLink:Database:SkipMigrations"] = "true", + }); + }); builder.UseSetting("ScadaLink:Node:Role", "Central"); builder.UseSetting("ScadaLink:Database:SkipMigrations", "true"); }); diff --git a/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj b/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj index fc88bb6..11e3c0e 100644 --- a/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj +++ b/tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj @@ -13,9 +13,11 @@ + + diff --git a/tests/ScadaLink.Host.Tests/SerilogTests.cs b/tests/ScadaLink.Host.Tests/SerilogTests.cs new file mode 100644 index 0000000..f24a0fa --- /dev/null +++ b/tests/ScadaLink.Host.Tests/SerilogTests.cs @@ -0,0 +1,72 @@ +using Serilog; +using Serilog.Events; + +namespace ScadaLink.Host.Tests; + +/// +/// WP-14: Tests for Serilog structured logging with enriched properties. +/// +public class SerilogTests +{ + [Fact] + public void SerilogLogger_EnrichesWithNodeProperties() + { + var sink = new InMemorySink(); + + var logger = new LoggerConfiguration() + .Enrich.WithProperty("SiteId", "TestSite") + .Enrich.WithProperty("NodeHostname", "test-node1") + .Enrich.WithProperty("NodeRole", "Site") + .WriteTo.Sink(sink) + .CreateLogger(); + + logger.Information("Test log message"); + + Assert.Single(sink.LogEvents); + var logEvent = sink.LogEvents[0]; + + Assert.True(logEvent.Properties.ContainsKey("SiteId")); + Assert.Equal("\"TestSite\"", logEvent.Properties["SiteId"].ToString()); + + Assert.True(logEvent.Properties.ContainsKey("NodeHostname")); + Assert.Equal("\"test-node1\"", logEvent.Properties["NodeHostname"].ToString()); + + Assert.True(logEvent.Properties.ContainsKey("NodeRole")); + Assert.Equal("\"Site\"", logEvent.Properties["NodeRole"].ToString()); + } + + [Fact] + public void SerilogLogger_CentralRole_EnrichesSiteIdAsCentral() + { + var sink = new InMemorySink(); + + var logger = new LoggerConfiguration() + .Enrich.WithProperty("SiteId", "central") + .Enrich.WithProperty("NodeHostname", "central-node1") + .Enrich.WithProperty("NodeRole", "Central") + .WriteTo.Sink(sink) + .CreateLogger(); + + logger.Warning("Central warning"); + + Assert.Single(sink.LogEvents); + var logEvent = sink.LogEvents[0]; + + Assert.Equal(LogEventLevel.Warning, logEvent.Level); + Assert.Equal("\"central\"", logEvent.Properties["SiteId"].ToString()); + Assert.Equal("\"Central\"", logEvent.Properties["NodeRole"].ToString()); + } +} + +/// +/// Simple in-memory Serilog sink for testing. +/// +public class InMemorySink : Serilog.Core.ILogEventSink +{ + public List LogEvents { get; } = new(); + + public void Emit(LogEvent logEvent) + { + LogEvents.Add(logEvent); + } +} diff --git a/tests/ScadaLink.Host.Tests/StartupValidatorTests.cs b/tests/ScadaLink.Host.Tests/StartupValidatorTests.cs new file mode 100644 index 0000000..137443c --- /dev/null +++ b/tests/ScadaLink.Host.Tests/StartupValidatorTests.cs @@ -0,0 +1,235 @@ +using Microsoft.Extensions.Configuration; + +namespace ScadaLink.Host.Tests; + +/// +/// WP-11: Tests for StartupValidator configuration validation. +/// +public class StartupValidatorTests +{ + private static IConfiguration BuildConfig(Dictionary values) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + } + + private static Dictionary ValidCentralConfig() => new() + { + ["ScadaLink:Node:Role"] = "Central", + ["ScadaLink:Node:NodeHostname"] = "central-node1", + ["ScadaLink:Node:RemotingPort"] = "8081", + ["ScadaLink:Database:ConfigurationDb"] = "Server=localhost;Database=Config;", + ["ScadaLink:Database:MachineDataDb"] = "Server=localhost;Database=MachineData;", + ["ScadaLink:Security:LdapServer"] = "ldap.example.com", + ["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-at-least-32-chars-long", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@central-node1:8081", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@central-node2:8081", + }; + + private static Dictionary ValidSiteConfig() => new() + { + ["ScadaLink:Node:Role"] = "Site", + ["ScadaLink:Node:NodeHostname"] = "site-a-node1", + ["ScadaLink:Node:SiteId"] = "SiteA", + ["ScadaLink:Node:RemotingPort"] = "8082", + ["ScadaLink:Database:SiteDbPath"] = "./data/scadalink.db", + ["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@site-a-node1:8082", + ["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node2:8082", + }; + + [Fact] + public void ValidCentralConfig_PassesValidation() + { + var config = BuildConfig(ValidCentralConfig()); + var ex = Record.Exception(() => StartupValidator.Validate(config)); + Assert.Null(ex); + } + + [Fact] + public void ValidSiteConfig_PassesValidation() + { + var config = BuildConfig(ValidSiteConfig()); + var ex = Record.Exception(() => StartupValidator.Validate(config)); + Assert.Null(ex); + } + + [Fact] + public void MissingRole_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Node:Role"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("Role must be 'Central' or 'Site'", ex.Message); + } + + [Fact] + public void InvalidRole_FailsValidation() + { + var values = ValidCentralConfig(); + values["ScadaLink:Node:Role"] = "Unknown"; + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("Role must be 'Central' or 'Site'", ex.Message); + } + + [Fact] + public void EmptyHostname_FailsValidation() + { + var values = ValidCentralConfig(); + values["ScadaLink:Node:NodeHostname"] = ""; + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("NodeHostname is required", ex.Message); + } + + [Fact] + public void MissingHostname_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Node:NodeHostname"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("NodeHostname is required", ex.Message); + } + + [Theory] + [InlineData("0")] + [InlineData("-1")] + [InlineData("65536")] + [InlineData("abc")] + [InlineData("")] + public void InvalidPort_FailsValidation(string port) + { + var values = ValidCentralConfig(); + values["ScadaLink:Node:RemotingPort"] = port; + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("RemotingPort must be 1-65535", ex.Message); + } + + [Theory] + [InlineData("1")] + [InlineData("8081")] + [InlineData("65535")] + public void ValidPort_PassesValidation(string port) + { + var values = ValidCentralConfig(); + values["ScadaLink:Node:RemotingPort"] = port; + var config = BuildConfig(values); + + var ex = Record.Exception(() => StartupValidator.Validate(config)); + Assert.Null(ex); + } + + [Fact] + public void Site_MissingSiteId_FailsValidation() + { + var values = ValidSiteConfig(); + values.Remove("ScadaLink:Node:SiteId"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("SiteId is required for Site nodes", ex.Message); + } + + [Fact] + public void Central_MissingConfigurationDb_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Database:ConfigurationDb"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("ConfigurationDb connection string required for Central", ex.Message); + } + + [Fact] + public void Central_MissingMachineDataDb_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Database:MachineDataDb"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("MachineDataDb connection string required for Central", ex.Message); + } + + [Fact] + public void Central_MissingLdapServer_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Security:LdapServer"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("LdapServer required for Central", ex.Message); + } + + [Fact] + public void Central_MissingJwtSigningKey_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Security:JwtSigningKey"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("JwtSigningKey required for Central", ex.Message); + } + + [Fact] + public void Site_MissingSiteDbPath_FailsValidation() + { + var values = ValidSiteConfig(); + values.Remove("ScadaLink:Database:SiteDbPath"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("SiteDbPath required for Site nodes", ex.Message); + } + + [Fact] + public void FewerThanTwoSeedNodes_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Cluster:SeedNodes:1"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("SeedNodes must have at least 2 entries", ex.Message); + } + + [Fact] + public void NoSeedNodes_FailsValidation() + { + var values = ValidCentralConfig(); + values.Remove("ScadaLink:Cluster:SeedNodes:0"); + values.Remove("ScadaLink:Cluster:SeedNodes:1"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("SeedNodes must have at least 2 entries", ex.Message); + } + + [Fact] + public void MultipleErrors_AllReported() + { + var values = new Dictionary + { + // Role is missing, hostname is missing, port is missing + }; + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("Role must be 'Central' or 'Site'", ex.Message); + Assert.Contains("NodeHostname is required", ex.Message); + Assert.Contains("RemotingPort must be 1-65535", ex.Message); + Assert.Contains("SeedNodes must have at least 2 entries", ex.Message); + } +} diff --git a/tests/ScadaLink.Host.Tests/WindowsServiceTests.cs b/tests/ScadaLink.Host.Tests/WindowsServiceTests.cs new file mode 100644 index 0000000..9f1e867 --- /dev/null +++ b/tests/ScadaLink.Host.Tests/WindowsServiceTests.cs @@ -0,0 +1,56 @@ +using System.Reflection; + +namespace ScadaLink.Host.Tests; + +/// +/// WP-17: Tests for Windows Service support. +/// Verifies UseWindowsService() is called in Program.cs. +/// +public class WindowsServiceTests +{ + [Fact] + public void ProgramCs_CallsUseWindowsService() + { + var hostProjectDir = FindHostProjectDirectory(); + Assert.NotNull(hostProjectDir); + + var programFile = Path.Combine(hostProjectDir, "Program.cs"); + Assert.True(File.Exists(programFile), "Program.cs not found"); + + var content = File.ReadAllText(programFile); + + // Verify UseWindowsService() is called for both Central and Site paths + var occurrences = content.Split("UseWindowsService()").Length - 1; + Assert.True(occurrences >= 2, + $"Expected UseWindowsService() to be called at least twice (Central and Site paths), found {occurrences} occurrence(s)"); + } + + [Fact] + public void HostProject_ReferencesWindowsServicesPackage() + { + var hostProjectDir = FindHostProjectDirectory(); + Assert.NotNull(hostProjectDir); + + var csprojFile = Path.Combine(hostProjectDir, "ScadaLink.Host.csproj"); + Assert.True(File.Exists(csprojFile), "ScadaLink.Host.csproj not found"); + + var content = File.ReadAllText(csprojFile); + Assert.Contains("Microsoft.Extensions.Hosting.WindowsServices", content); + } + + 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; + } + + return null; + } +} diff --git a/tests/ScadaLink.IntegrationTests/AuditTransactionTests.cs b/tests/ScadaLink.IntegrationTests/AuditTransactionTests.cs new file mode 100644 index 0000000..ea89885 --- /dev/null +++ b/tests/ScadaLink.IntegrationTests/AuditTransactionTests.cs @@ -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; + +/// +/// WP-22: Audit transactional guarantee — entity change + audit log in same transaction. +/// +public class AuditTransactionTests : IClassFixture +{ + 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(); + var auditService = scope.ServiceProvider.GetRequiredService(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // 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(); + var auditService = scope1.ServiceProvider.GetRequiredService(); + + // 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(); + var dbContext2 = scope2.ServiceProvider.GetRequiredService(); + + 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"); + } +} diff --git a/tests/ScadaLink.IntegrationTests/AuthFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuthFlowTests.cs new file mode 100644 index 0000000..ec2e3e1 --- /dev/null +++ b/tests/ScadaLink.IntegrationTests/AuthFlowTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using ScadaLink.CentralUI.Auth; +using ScadaLink.Security; + +namespace ScadaLink.IntegrationTests; + +/// +/// WP-22: Auth flow integration tests. +/// Tests that require a running LDAP server are marked with Integration trait. +/// +public class AuthFlowTests : IClassFixture +{ + 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("username", ""), + new KeyValuePair("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(); + + 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(); + + 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("username", "admin"), + new KeyValuePair("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); + } +} diff --git a/tests/ScadaLink.IntegrationTests/ReadinessTests.cs b/tests/ScadaLink.IntegrationTests/ReadinessTests.cs new file mode 100644 index 0000000..05395d3 --- /dev/null +++ b/tests/ScadaLink.IntegrationTests/ReadinessTests.cs @@ -0,0 +1,30 @@ +using System.Net; + +namespace ScadaLink.IntegrationTests; + +/// +/// WP-22: Readiness gating — /health/ready endpoint returns status code. +/// +public class ReadinessTests : IClassFixture +{ + 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}"); + } +} diff --git a/tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj b/tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj new file mode 100644 index 0000000..9a2fa85 --- /dev/null +++ b/tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ScadaLink.IntegrationTests/ScadaLinkWebApplicationFactory.cs b/tests/ScadaLink.IntegrationTests/ScadaLinkWebApplicationFactory.cs new file mode 100644 index 0000000..a3e34ee --- /dev/null +++ b/tests/ScadaLink.IntegrationTests/ScadaLinkWebApplicationFactory.cs @@ -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; + +/// +/// 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. +/// +public class ScadaLinkWebApplicationFactory : WebApplicationFactory +{ + /// + /// Environment variables that were set by this factory, to be cleaned up on dispose. + /// + private readonly Dictionary _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 + { + ["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) || + 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(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); + } + } + } +} diff --git a/tests/ScadaLink.IntegrationTests/StartupValidationTests.cs b/tests/ScadaLink.IntegrationTests/StartupValidationTests.cs new file mode 100644 index 0000000..dd924c3 --- /dev/null +++ b/tests/ScadaLink.IntegrationTests/StartupValidationTests.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace ScadaLink.IntegrationTests; + +/// +/// 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. +/// +public class StartupValidationTests +{ + [Fact] + public void MissingRole_ThrowsInvalidOperationException() + { + // Set all required config EXCEPT Role + using var env = new TempEnvironment(new Dictionary + { + ["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(); + + var ex = Assert.Throws(() => factory.CreateClient()); + Assert.Contains("Role", ex.Message, StringComparison.OrdinalIgnoreCase); + + factory.Dispose(); + } + + [Fact] + public void MissingJwtSigningKey_ForCentral_ThrowsInvalidOperationException() + { + using var env = new TempEnvironment(new Dictionary + { + ["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(); + + var ex = Assert.Throws(() => 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); + } + + /// + /// Helper to temporarily set environment variables and restore them on dispose. + /// Clears all ScadaLink__ vars first to ensure a clean slate. + /// + private sealed class TempEnvironment : IDisposable + { + private readonly Dictionary _previousValues = new(); + + /// + /// All ScadaLink env vars that might be set by other tests/factories. + /// + 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 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); + } + } + } +} diff --git a/tests/ScadaLink.IntegrationTests/xunit.runner.json b/tests/ScadaLink.IntegrationTests/xunit.runner.json new file mode 100644 index 0000000..dd80f43 --- /dev/null +++ b/tests/ScadaLink.IntegrationTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +}