Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d38356efdb | |||
| cafb7d2006 | |||
| 1996b21961 |
@@ -36,5 +36,6 @@
|
||||
<Project Path="tests/ScadaLink.ClusterInfrastructure.Tests/ScadaLink.ClusterInfrastructure.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.InboundAPI.Tests/ScadaLink.InboundAPI.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.ConfigurationDatabase.Tests/ScadaLink.ConfigurationDatabase.Tests.csproj" />
|
||||
<Project Path="tests/ScadaLink.IntegrationTests/ScadaLink.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<LdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AuthenticationState> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ScadaLink</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1p0TQGL3BNgcree90f9QM0jB1zDTkM6"
|
||||
crossorigin="anonymous" />
|
||||
<style>
|
||||
.sidebar {
|
||||
min-width: 220px;
|
||||
max-width: 220px;
|
||||
min-height: 100vh;
|
||||
background-color: #212529;
|
||||
}
|
||||
.sidebar .nav-link {
|
||||
color: #adb5bd;
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sidebar .nav-link:hover {
|
||||
color: #fff;
|
||||
background-color: #343a40;
|
||||
}
|
||||
.sidebar .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: #0d6efd;
|
||||
}
|
||||
.sidebar .nav-section-header {
|
||||
color: #6c757d;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.sidebar .brand {
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #343a40;
|
||||
}
|
||||
#reconnect-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 9999;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
#reconnect-modal .modal-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#reconnect-modal .modal-content {
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
<body>
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(App).Assembly">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
|
||||
<NotAuthorized>
|
||||
@if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
<RedirectToLogin />
|
||||
}
|
||||
else
|
||||
{
|
||||
<NotAuthorizedView />
|
||||
}
|
||||
</NotAuthorized>
|
||||
<Authorizing>
|
||||
<p class="text-muted p-3">Checking authorization...</p>
|
||||
</Authorizing>
|
||||
</AuthorizeRouteView>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="typeof(MainLayout)">
|
||||
<div class="container mt-5">
|
||||
<h3>Page Not Found</h3>
|
||||
<p class="text-muted">The requested page does not exist.</p>
|
||||
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
|
||||
</div>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
|
||||
<div id="reconnect-modal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">Reconnecting...</span>
|
||||
</div>
|
||||
<h5>Connection Lost</h5>
|
||||
<p class="text-muted mb-0">Attempting to reconnect to the server. Please wait...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.server.js"></script>
|
||||
<script>
|
||||
Blazor.defaultReconnectionHandler._reconnectCallback = function (d) {
|
||||
document.getElementById('reconnect-modal').style.display = 'block';
|
||||
};
|
||||
Blazor.defaultReconnectionHandler._reconnectedCallback = function (d) {
|
||||
document.getElementById('reconnect-modal').style.display = 'none';
|
||||
};
|
||||
Blazor.defaultReconnectionHandler._reconnectionFailedCallback = function (d) {
|
||||
document.getElementById('reconnect-modal').querySelector('p').textContent =
|
||||
'Unable to reconnect. Please refresh the page.';
|
||||
};
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||
crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex">
|
||||
<NavMenu />
|
||||
<main class="flex-grow-1 p-3" style="min-height: 100vh; background-color: #f8f9fa;">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
@using ScadaLink.Security
|
||||
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="brand">ScadaLink</div>
|
||||
|
||||
<ul class="nav flex-column flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
||||
</li>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@* Admin section — Admin role only *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||
<Authorized Context="adminContext">
|
||||
<li class="nav-section-header">Admin</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="admin/sites">Sites</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="admin/areas">Areas</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Design section — Design role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDesign">
|
||||
<Authorized Context="designContext">
|
||||
<li class="nav-section-header">Design</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="design/templates">Templates</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="design/shared-scripts">Shared Scripts</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="design/external-systems">External Systems</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Deployment section — Deployment role *@
|
||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireDeployment">
|
||||
<Authorized Context="deploymentContext">
|
||||
<li class="nav-section-header">Deployment</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="deployment/instances">Instances</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="deployment/deployments">Deployments</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="deployment/debug-view">Debug View</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@* Health — visible to all authenticated users *@
|
||||
<li class="nav-section-header">Monitoring</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="monitoring/health">Health Dashboard</NavLink>
|
||||
</li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="border-top border-secondary p-2">
|
||||
<span class="d-block text-light small px-2">@context.User.FindFirst("DisplayName")?.Value</span>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button type="submit" class="btn btn-link btn-sm text-muted text-decoration-none px-2">Sign Out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</nav>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/admin/areas"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Areas</h4>
|
||||
<p class="text-muted">Area management will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">LDAP Group Mappings</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="ShowAddForm">Add Mapping</button>
|
||||
</div>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p class="text-muted">Loading...</p>
|
||||
}
|
||||
else if (_errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@_errorMessage</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Add / Edit form *@
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">@(_editingMapping == null ? "Add New Mapping" : "Edit Mapping")</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">LDAP Group Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formGroupName" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Role</label>
|
||||
<select class="form-select form-select-sm" @bind="_formRole">
|
||||
<option value="">Select role...</option>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Design">Design</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_formError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Mappings table *@
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>LDAP Group Name</th>
|
||||
<th>Role</th>
|
||||
<th>Site Scope Rules</th>
|
||||
<th style="width: 200px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (_mappings.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-center">No mappings configured.</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var mapping in _mappings)
|
||||
{
|
||||
<tr>
|
||||
<td>@mapping.Id</td>
|
||||
<td>@mapping.LdapGroupName</td>
|
||||
<td><span class="badge bg-secondary">@mapping.Role</span></td>
|
||||
<td>
|
||||
@{
|
||||
var rules = _scopeRules.GetValueOrDefault(mapping.Id);
|
||||
}
|
||||
@if (rules != null && rules.Count > 0)
|
||||
{
|
||||
@foreach (var rule in rules)
|
||||
{
|
||||
<span class="badge bg-info text-dark me-1">Site @rule.SiteId</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">All sites</span>
|
||||
}
|
||||
@if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm ms-2 py-0 px-1"
|
||||
@onclick="() => ShowScopeRuleForm(mapping.Id)">+ Scope</button>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
|
||||
@onclick="() => EditMapping(mapping)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
@onclick="() => DeleteMapping(mapping.Id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@* Scope rule form *@
|
||||
@if (_showScopeRuleForm)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Add Site Scope Rule (Mapping #@_scopeRuleMappingId)</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Site ID</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="_scopeRuleSiteId" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveScopeRule">Add</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelScopeRuleForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (_scopeRuleError != null)
|
||||
{
|
||||
<div class="text-danger small mt-1">@_scopeRuleError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<LdapGroupMapping> _mappings = new();
|
||||
private Dictionary<int, List<SiteScopeRule>> _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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/admin/sites"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Sites</h4>
|
||||
<p class="text-muted">Site management will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h3>Welcome to ScadaLink</h3>
|
||||
<p class="text-muted">Central management console for the ScadaLink SCADA system.</p>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="card mt-3" style="max-width: 500px;">
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle mb-2 text-muted">Signed in as</h6>
|
||||
<p class="card-text mb-1"><strong>@context.User.FindFirst("DisplayName")?.Value</strong></p>
|
||||
<p class="card-text small text-muted mb-2">@context.User.FindFirst("Username")?.Value</p>
|
||||
|
||||
@{
|
||||
var roles = context.User.FindAll("Role").Select(c => c.Value).ToList();
|
||||
}
|
||||
@if (roles.Count > 0)
|
||||
{
|
||||
<h6 class="card-subtitle mb-1 mt-3 text-muted">Roles</h6>
|
||||
<div>
|
||||
@foreach (var role in roles)
|
||||
{
|
||||
<span class="badge bg-secondary me-1">@role</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/deployment/debug-view"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Debug View</h4>
|
||||
<p class="text-muted">Real-time debug view will be available in a future phase.</p>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Note:</strong> Debug view streams are lost on failover. If the connection drops, you will need to re-open the debug view.
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/deployment/deployments"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Deployments</h4>
|
||||
<p class="text-muted">Deployment management will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/deployment/instances"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Instances</h4>
|
||||
<p class="text-muted">Instance management will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/design/external-systems"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>External Systems</h4>
|
||||
<p class="text-muted">External system management will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/design/shared-scripts"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Shared Scripts</h4>
|
||||
<p class="text-muted">Shared script management will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@page "/design/templates"
|
||||
@using ScadaLink.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Templates</h4>
|
||||
<p class="text-muted">Template management will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
@page "/login"
|
||||
|
||||
<div class="container" style="max-width: 400px; margin-top: 10vh;">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="card-title mb-4 text-center">ScadaLink</h4>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
|
||||
}
|
||||
|
||||
<form method="post" action="/auth/login">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
required autocomplete="username" autofocus />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
required autocomplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-muted mt-3 small">Authenticate with your organization's LDAP credentials.</p>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/monitoring/health"
|
||||
@attribute [Authorize]
|
||||
|
||||
<div class="container mt-4">
|
||||
<h4>Health Dashboard</h4>
|
||||
<p class="text-muted">Site health monitoring will be available in a future phase.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="container mt-5">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h5 class="alert-heading">Not Authorized</h5>
|
||||
<p class="mb-0">You do not have permission to access this page. Contact your administrator if you believe this is an error.</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-outline-primary btn-sm">Return to Dashboard</a>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -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<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
||||
services.AddCascadingAuthenticationState();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -6,6 +6,9 @@ public class LdapGroupMapping
|
||||
public string LdapGroupName { get; set; }
|
||||
public string Role { get; set; }
|
||||
|
||||
// Parameterless constructor for EF Core seed data
|
||||
private LdapGroupMapping() { LdapGroupName = null!; Role = null!; }
|
||||
|
||||
public LdapGroupMapping(string ldapGroupName, string role)
|
||||
{
|
||||
LdapGroupName = ldapGroupName ?? throw new ArgumentNullException(nameof(ldapGroupName));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
@@ -15,5 +16,18 @@ public interface ICentralUiRepository
|
||||
Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Area>> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
// Audit log queries
|
||||
Task<(IReadOnlyList<AuditLogEntry> Entries, int TotalCount)> GetAuditLogEntriesAsync(
|
||||
string? user = null,
|
||||
string? entityType = null,
|
||||
string? action = null,
|
||||
DateTimeOffset? from = null,
|
||||
DateTimeOffset? to = null,
|
||||
string? entityId = null,
|
||||
string? entityName = null,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class AuditLogEntryConfiguration : IEntityTypeConfiguration<AuditLogEntry>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLogEntry> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.User)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.Action)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(a => a.EntityType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.EntityId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.EntityName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// Indexes for common query patterns
|
||||
builder.HasIndex(a => a.Timestamp);
|
||||
builder.HasIndex(a => a.User);
|
||||
builder.HasIndex(a => a.EntityType);
|
||||
builder.HasIndex(a => a.EntityId);
|
||||
builder.HasIndex(a => a.Action);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class DeploymentRecordConfiguration : IEntityTypeConfiguration<DeploymentRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DeploymentRecord> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.DeploymentId)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(d => d.RevisionHash)
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(d => d.DeployedBy)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(d => d.Status)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasOne<Instance>()
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Optimistic concurrency on deployment status records
|
||||
builder.Property<byte[]>("RowVersion")
|
||||
.IsRowVersion();
|
||||
|
||||
builder.HasIndex(d => d.DeploymentId).IsUnique();
|
||||
builder.HasIndex(d => d.InstanceId);
|
||||
builder.HasIndex(d => d.DeployedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public class SystemArtifactDeploymentRecordConfiguration : IEntityTypeConfiguration<SystemArtifactDeploymentRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SystemArtifactDeploymentRecord> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.ArtifactType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(d => d.DeployedBy)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(d => d.PerSiteStatus)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(d => d.DeployedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class ExternalSystemDefinitionConfiguration : IEntityTypeConfiguration<ExternalSystemDefinition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ExternalSystemDefinition> builder)
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(e => e.EndpointUrl)
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(e => e.AuthType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(e => e.AuthConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasMany<ExternalSystemMethod>()
|
||||
.WithOne()
|
||||
.HasForeignKey(m => m.ExternalSystemDefinitionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(e => e.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class ExternalSystemMethodConfiguration : IEntityTypeConfiguration<ExternalSystemMethod>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ExternalSystemMethod> builder)
|
||||
{
|
||||
builder.HasKey(m => m.Id);
|
||||
|
||||
builder.Property(m => m.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(m => m.HttpMethod)
|
||||
.IsRequired()
|
||||
.HasMaxLength(10);
|
||||
|
||||
builder.Property(m => m.Path)
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(m => m.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(m => m.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(m => new { m.ExternalSystemDefinitionId, m.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class DatabaseConnectionDefinitionConfiguration : IEntityTypeConfiguration<DatabaseConnectionDefinition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DatabaseConnectionDefinition> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(d => d.ConnectionString)
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(d => d.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ApiKey> builder)
|
||||
{
|
||||
builder.HasKey(k => k.Id);
|
||||
|
||||
builder.Property(k => k.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(k => k.KeyValue)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.HasIndex(k => k.Name).IsUnique();
|
||||
builder.HasIndex(k => k.KeyValue).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiMethodConfiguration : IEntityTypeConfiguration<ApiMethod>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ApiMethod> builder)
|
||||
{
|
||||
builder.HasKey(m => m.Id);
|
||||
|
||||
builder.Property(m => m.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(m => m.Script)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.ApprovedApiKeyIds)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(m => m.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(m => m.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(m => m.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Instance> builder)
|
||||
{
|
||||
builder.HasKey(i => i.Id);
|
||||
|
||||
builder.Property(i => i.UniqueName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(i => i.State)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasOne<Template>()
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.SiteId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne<Area>()
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.AreaId)
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasMany(i => i.AttributeOverrides)
|
||||
.WithOne()
|
||||
.HasForeignKey(o => o.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(i => i.ConnectionBindings)
|
||||
.WithOne()
|
||||
.HasForeignKey(b => b.InstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(i => new { i.SiteId, i.UniqueName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceAttributeOverrideConfiguration : IEntityTypeConfiguration<InstanceAttributeOverride>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InstanceAttributeOverride> builder)
|
||||
{
|
||||
builder.HasKey(o => o.Id);
|
||||
|
||||
builder.Property(o => o.AttributeName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(o => o.OverrideValue)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(o => new { o.InstanceId, o.AttributeName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class InstanceConnectionBindingConfiguration : IEntityTypeConfiguration<InstanceConnectionBinding>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<InstanceConnectionBinding> builder)
|
||||
{
|
||||
builder.HasKey(b => b.Id);
|
||||
|
||||
builder.Property(b => b.AttributeName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.HasOne<DataConnection>()
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.DataConnectionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(b => new { b.InstanceId, b.AttributeName }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class AreaConfiguration : IEntityTypeConfiguration<Area>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Area> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.SiteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Self-referencing parent area
|
||||
builder.HasOne<Area>()
|
||||
.WithMany(a => a.Children)
|
||||
.HasForeignKey(a => a.ParentAreaId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasIndex(a => new { a.SiteId, a.ParentAreaId, a.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class NotificationListConfiguration : IEntityTypeConfiguration<NotificationList>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NotificationList> builder)
|
||||
{
|
||||
builder.HasKey(n => n.Id);
|
||||
|
||||
builder.Property(n => n.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.HasMany(n => n.Recipients)
|
||||
.WithOne()
|
||||
.HasForeignKey(r => r.NotificationListId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(n => n.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationRecipientConfiguration : IEntityTypeConfiguration<NotificationRecipient>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<NotificationRecipient> builder)
|
||||
{
|
||||
builder.HasKey(r => r.Id);
|
||||
|
||||
builder.Property(r => r.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(r => r.EmailAddress)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
}
|
||||
}
|
||||
|
||||
public class SmtpConfigurationConfiguration : IEntityTypeConfiguration<SmtpConfiguration>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SmtpConfiguration> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Host)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(s => s.AuthType)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(s => s.Credentials)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(s => s.TlsMode)
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(s => s.FromAddress)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class SharedScriptConfiguration : IEntityTypeConfiguration<SharedScript>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SharedScript> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(s => s.Code)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(s => s.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(s => s.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Security;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupMapping>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LdapGroupMapping> builder)
|
||||
{
|
||||
builder.HasKey(m => m.Id);
|
||||
|
||||
builder.Property(m => m.LdapGroupName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(m => m.Role)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.HasIndex(m => m.LdapGroupName).IsUnique();
|
||||
|
||||
// Seed default admin mapping
|
||||
builder.HasData(new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 });
|
||||
}
|
||||
}
|
||||
|
||||
public class SiteScopeRuleConfiguration : IEntityTypeConfiguration<SiteScopeRule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SiteScopeRule> builder)
|
||||
{
|
||||
builder.HasKey(r => r.Id);
|
||||
|
||||
builder.HasOne<LdapGroupMapping>()
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.LdapGroupMappingId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.SiteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(r => new { r.LdapGroupMappingId, r.SiteId }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class SiteConfiguration : IEntityTypeConfiguration<Site>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Site> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(s => s.SiteIdentifier)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property(s => s.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.HasIndex(s => s.Name).IsUnique();
|
||||
builder.HasIndex(s => s.SiteIdentifier).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnection>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DataConnection> builder)
|
||||
{
|
||||
builder.HasKey(d => d.Id);
|
||||
|
||||
builder.Property(d => d.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(d => d.Protocol)
|
||||
.IsRequired()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(d => d.Configuration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(d => d.Name).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class SiteDataConnectionAssignmentConfiguration : IEntityTypeConfiguration<SiteDataConnectionAssignment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SiteDataConnectionAssignment> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.HasOne<Site>()
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.SiteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne<DataConnection>()
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.DataConnectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(a => new { a.SiteId, a.DataConnectionId }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
public class TemplateConfiguration : IEntityTypeConfiguration<Template>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Template> builder)
|
||||
{
|
||||
builder.HasKey(t => t.Id);
|
||||
|
||||
builder.Property(t => t.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(t => t.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.HasIndex(t => t.Name).IsUnique();
|
||||
|
||||
// Self-referencing parent template (inheritance)
|
||||
builder.HasOne<Template>()
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.ParentTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasMany(t => t.Attributes)
|
||||
.WithOne()
|
||||
.HasForeignKey(a => a.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(t => t.Alarms)
|
||||
.WithOne()
|
||||
.HasForeignKey(a => a.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(t => t.Scripts)
|
||||
.WithOne()
|
||||
.HasForeignKey(s => s.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(t => t.Compositions)
|
||||
.WithOne()
|
||||
.HasForeignKey(c => c.TemplateId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateAttributeConfiguration : IEntityTypeConfiguration<TemplateAttribute>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TemplateAttribute> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.Value)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(a => a.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(a => a.DataSourceReference)
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(a => a.DataType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateAlarmConfiguration : IEntityTypeConfiguration<TemplateAlarm>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TemplateAlarm> builder)
|
||||
{
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Property(a => a.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(a => a.Description)
|
||||
.HasMaxLength(2000);
|
||||
|
||||
builder.Property(a => a.TriggerType)
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(a => a.TriggerConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(a => new { a.TemplateId, a.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateScriptConfiguration : IEntityTypeConfiguration<TemplateScript>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TemplateScript> builder)
|
||||
{
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property(s => s.Code)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.TriggerType)
|
||||
.HasMaxLength(50);
|
||||
|
||||
builder.Property(s => s.TriggerConfiguration)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(s => s.ParameterDefinitions)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.Property(s => s.ReturnDefinition)
|
||||
.HasMaxLength(4000);
|
||||
|
||||
builder.HasIndex(s => new { s.TemplateId, s.Name }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public class TemplateCompositionConfiguration : IEntityTypeConfiguration<TemplateComposition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TemplateComposition> builder)
|
||||
{
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
builder.Property(c => c.InstanceName)
|
||||
.IsRequired()
|
||||
.HasMaxLength(200);
|
||||
|
||||
// The composed template reference
|
||||
builder.HasOne<Template>()
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ComposedTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(c => new { c.TemplateId, c.InstanceName }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating DbContext instances at design time (used by dotnet ef tooling).
|
||||
/// Reads connection string from Host's appsettings.Central.json.
|
||||
/// </summary>
|
||||
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScadaLinkDbContext>
|
||||
{
|
||||
public ScadaLinkDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "..", "ScadaLink.Host"))
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddJsonFile("appsettings.Central.json", optional: true)
|
||||
.Build();
|
||||
|
||||
var connectionString = configuration["ScadaLink:Database:ConfigurationDb"]
|
||||
?? "Server=localhost,1433;Database=ScadaLink_Config;User Id=sa;Password=YourPassword;TrustServerCertificate=True";
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ScadaLinkDbContext>();
|
||||
optionsBuilder.UseSqlServer(connectionString);
|
||||
|
||||
return new ScadaLinkDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase;
|
||||
|
||||
/// <summary>
|
||||
/// Provides environment-aware migration behavior for the ScadaLink configuration database.
|
||||
/// </summary>
|
||||
public static class MigrationHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies pending migrations (development mode) or validates schema version (production mode).
|
||||
/// </summary>
|
||||
/// <param name="dbContext">The database context to migrate or validate.</param>
|
||||
/// <param name="isDevelopment">When true, auto-applies migrations. When false, validates schema version matches.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static async Task ApplyOrValidateMigrationsAsync(
|
||||
ScadaLinkDbContext dbContext,
|
||||
bool isDevelopment,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (isDevelopment)
|
||||
{
|
||||
await dbContext.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(cancellationToken);
|
||||
var pending = pendingMigrations.ToList();
|
||||
if (pending.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Database schema is out of date. {pending.Count} pending migration(s): {string.Join(", ", pending)}. " +
|
||||
"Apply migrations using 'dotnet ef database update' or the generated SQL scripts before starting in production mode.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1144
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,883 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
KeyValue = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiKeys", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiMethods",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Script = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ApprovedApiKeyIds = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
TimeoutSeconds = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiMethods", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AuditLogEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
User = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Action = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
EntityType = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EntityId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EntityName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
AfterStateJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Timestamp = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AuditLogEntries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DatabaseConnectionDefinitions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
ConnectionString = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: false),
|
||||
MaxRetries = table.Column<int>(type: "int", nullable: false),
|
||||
RetryDelay = table.Column<TimeSpan>(type: "time", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DatabaseConnectionDefinitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DataConnections",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Protocol = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Configuration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DataConnections", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalSystemDefinitions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EndpointUrl = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||
AuthType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
AuthConfiguration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
MaxRetries = table.Column<int>(type: "int", nullable: false),
|
||||
RetryDelay = table.Column<TimeSpan>(type: "time", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalSystemDefinitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LdapGroupMappings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LdapGroupName = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Role = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LdapGroupMappings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationLists",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationLists", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SharedScripts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Code = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SharedScripts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Sites",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
SiteIdentifier = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Sites", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SmtpConfigurations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Host = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
Port = table.Column<int>(type: "int", nullable: false),
|
||||
AuthType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
Credentials = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
TlsMode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
FromAddress = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
|
||||
ConnectionTimeoutSeconds = table.Column<int>(type: "int", nullable: false),
|
||||
MaxConcurrentConnections = table.Column<int>(type: "int", nullable: false),
|
||||
MaxRetries = table.Column<int>(type: "int", nullable: false),
|
||||
RetryDelay = table.Column<TimeSpan>(type: "time", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SmtpConfigurations", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SystemArtifactDeploymentRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ArtifactType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
DeployedBy = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
DeployedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
PerSiteStatus = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SystemArtifactDeploymentRecords", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Templates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
ParentTemplateId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Templates", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Templates_Templates_ParentTemplateId",
|
||||
column: x => x.ParentTemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalSystemMethods",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ExternalSystemDefinitionId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
HttpMethod = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
|
||||
Path = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalSystemMethods", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ExternalSystemMethods_ExternalSystemDefinitions_ExternalSystemDefinitionId",
|
||||
column: x => x.ExternalSystemDefinitionId,
|
||||
principalTable: "ExternalSystemDefinitions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationRecipients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
NotificationListId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
EmailAddress = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationRecipients", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationRecipients_NotificationLists_NotificationListId",
|
||||
column: x => x.NotificationListId,
|
||||
principalTable: "NotificationLists",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Areas",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
ParentAreaId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Areas", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Areas_Areas_ParentAreaId",
|
||||
column: x => x.ParentAreaId,
|
||||
principalTable: "Areas",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Areas_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteDataConnectionAssignments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false),
|
||||
DataConnectionId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteDataConnectionAssignments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteDataConnectionAssignments_DataConnections_DataConnectionId",
|
||||
column: x => x.DataConnectionId,
|
||||
principalTable: "DataConnections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteDataConnectionAssignments_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SiteScopeRules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
LdapGroupMappingId = table.Column<int>(type: "int", nullable: false),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SiteScopeRules", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteScopeRules_LdapGroupMappings_LdapGroupMappingId",
|
||||
column: x => x.LdapGroupMappingId,
|
||||
principalTable: "LdapGroupMappings",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SiteScopeRules_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateAlarms",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
PriorityLevel = table.Column<int>(type: "int", nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
TriggerType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
TriggerConfiguration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
OnTriggerScriptId = table.Column<int>(type: "int", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateAlarms", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateAlarms_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateAttributes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
DataType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||
DataSourceReference = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateAttributes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateAttributes_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateCompositions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
ComposedTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
InstanceName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateCompositions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateCompositions_Templates_ComposedTemplateId",
|
||||
column: x => x.ComposedTemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateCompositions_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TemplateScripts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
IsLocked = table.Column<bool>(type: "bit", nullable: false),
|
||||
Code = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
TriggerType = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
TriggerConfiguration = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ParameterDefinitions = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
ReturnDefinition = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true),
|
||||
MinTimeBetweenRuns = table.Column<TimeSpan>(type: "time", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TemplateScripts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_TemplateScripts_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Instances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
TemplateId = table.Column<int>(type: "int", nullable: false),
|
||||
SiteId = table.Column<int>(type: "int", nullable: false),
|
||||
AreaId = table.Column<int>(type: "int", nullable: true),
|
||||
UniqueName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Instances", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Instances_Areas_AreaId",
|
||||
column: x => x.AreaId,
|
||||
principalTable: "Areas",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_Instances_Sites_SiteId",
|
||||
column: x => x.SiteId,
|
||||
principalTable: "Sites",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Instances_Templates_TemplateId",
|
||||
column: x => x.TemplateId,
|
||||
principalTable: "Templates",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DeploymentRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
|
||||
DeploymentId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
RevisionHash = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
DeployedBy = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
DeployedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
|
||||
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DeploymentRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_DeploymentRecords_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstanceAttributeOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
AttributeName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
OverrideValue = table.Column<string>(type: "nvarchar(4000)", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstanceAttributeOverrides", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceAttributeOverrides_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstanceConnectionBindings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
InstanceId = table.Column<int>(type: "int", nullable: false),
|
||||
AttributeName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
DataConnectionId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstanceConnectionBindings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceConnectionBindings_DataConnections_DataConnectionId",
|
||||
column: x => x.DataConnectionId,
|
||||
principalTable: "DataConnections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstanceConnectionBindings_Instances_InstanceId",
|
||||
column: x => x.InstanceId,
|
||||
principalTable: "Instances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_KeyValue",
|
||||
table: "ApiKeys",
|
||||
column: "KeyValue",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKeys_Name",
|
||||
table: "ApiKeys",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiMethods_Name",
|
||||
table: "ApiMethods",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Areas_ParentAreaId",
|
||||
table: "Areas",
|
||||
column: "ParentAreaId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Areas_SiteId_ParentAreaId_Name",
|
||||
table: "Areas",
|
||||
columns: new[] { "SiteId", "ParentAreaId", "Name" },
|
||||
unique: true,
|
||||
filter: "[ParentAreaId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_Action",
|
||||
table: "AuditLogEntries",
|
||||
column: "Action");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_EntityId",
|
||||
table: "AuditLogEntries",
|
||||
column: "EntityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_EntityType",
|
||||
table: "AuditLogEntries",
|
||||
column: "EntityType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_Timestamp",
|
||||
table: "AuditLogEntries",
|
||||
column: "Timestamp");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogEntries_User",
|
||||
table: "AuditLogEntries",
|
||||
column: "User");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DatabaseConnectionDefinitions_Name",
|
||||
table: "DatabaseConnectionDefinitions",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DataConnections_Name",
|
||||
table: "DataConnections",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeployedAt",
|
||||
table: "DeploymentRecords",
|
||||
column: "DeployedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_DeploymentId",
|
||||
table: "DeploymentRecords",
|
||||
column: "DeploymentId",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DeploymentRecords_InstanceId",
|
||||
table: "DeploymentRecords",
|
||||
column: "InstanceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalSystemDefinitions_Name",
|
||||
table: "ExternalSystemDefinitions",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalSystemMethods_ExternalSystemDefinitionId_Name",
|
||||
table: "ExternalSystemMethods",
|
||||
columns: new[] { "ExternalSystemDefinitionId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceAttributeOverrides_InstanceId_AttributeName",
|
||||
table: "InstanceAttributeOverrides",
|
||||
columns: new[] { "InstanceId", "AttributeName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceConnectionBindings_DataConnectionId",
|
||||
table: "InstanceConnectionBindings",
|
||||
column: "DataConnectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstanceConnectionBindings_InstanceId_AttributeName",
|
||||
table: "InstanceConnectionBindings",
|
||||
columns: new[] { "InstanceId", "AttributeName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Instances_AreaId",
|
||||
table: "Instances",
|
||||
column: "AreaId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Instances_SiteId_UniqueName",
|
||||
table: "Instances",
|
||||
columns: new[] { "SiteId", "UniqueName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Instances_TemplateId",
|
||||
table: "Instances",
|
||||
column: "TemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LdapGroupMappings_LdapGroupName",
|
||||
table: "LdapGroupMappings",
|
||||
column: "LdapGroupName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLists_Name",
|
||||
table: "NotificationLists",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationRecipients_NotificationListId",
|
||||
table: "NotificationRecipients",
|
||||
column: "NotificationListId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SharedScripts_Name",
|
||||
table: "SharedScripts",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteDataConnectionAssignments_DataConnectionId",
|
||||
table: "SiteDataConnectionAssignments",
|
||||
column: "DataConnectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteDataConnectionAssignments_SiteId_DataConnectionId",
|
||||
table: "SiteDataConnectionAssignments",
|
||||
columns: new[] { "SiteId", "DataConnectionId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Sites_Name",
|
||||
table: "Sites",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Sites_SiteIdentifier",
|
||||
table: "Sites",
|
||||
column: "SiteIdentifier",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteScopeRules_LdapGroupMappingId_SiteId",
|
||||
table: "SiteScopeRules",
|
||||
columns: new[] { "LdapGroupMappingId", "SiteId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SiteScopeRules_SiteId",
|
||||
table: "SiteScopeRules",
|
||||
column: "SiteId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SystemArtifactDeploymentRecords_DeployedAt",
|
||||
table: "SystemArtifactDeploymentRecords",
|
||||
column: "DeployedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateAlarms_TemplateId_Name",
|
||||
table: "TemplateAlarms",
|
||||
columns: new[] { "TemplateId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateAttributes_TemplateId_Name",
|
||||
table: "TemplateAttributes",
|
||||
columns: new[] { "TemplateId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateCompositions_ComposedTemplateId",
|
||||
table: "TemplateCompositions",
|
||||
column: "ComposedTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateCompositions_TemplateId_InstanceName",
|
||||
table: "TemplateCompositions",
|
||||
columns: new[] { "TemplateId", "InstanceName" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_Name",
|
||||
table: "Templates",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Templates_ParentTemplateId",
|
||||
table: "Templates",
|
||||
column: "ParentTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TemplateScripts_TemplateId_Name",
|
||||
table: "TemplateScripts",
|
||||
columns: new[] { "TemplateId", "Name" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKeys");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiMethods");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AuditLogEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DatabaseConnectionDefinitions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DeploymentRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalSystemMethods");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstanceAttributeOverrides");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstanceConnectionBindings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationRecipients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SharedScripts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteDataConnectionAssignments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SiteScopeRules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SmtpConfigurations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SystemArtifactDeploymentRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateAlarms");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateAttributes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateCompositions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TemplateScripts");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalSystemDefinitions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Instances");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationLists");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DataConnections");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LdapGroupMappings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Areas");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Templates");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Sites");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1152
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SeedData : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "LdapGroupMappings",
|
||||
columns: new[] { "Id", "LdapGroupName", "Role" },
|
||||
values: new object[] { 1, "SCADA-Admins", "Admin" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "LdapGroupMappings",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class CentralUiRepository : ICentralUiRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public CentralUiRepository(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Site>> GetAllSitesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Sites
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SiteDataConnectionAssignments
|
||||
.AsNoTracking()
|
||||
.Where(a => a.SiteId == siteId)
|
||||
.Join(_context.DataConnections, a => a.DataConnectionId, d => d.Id, (_, d) => d)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SiteDataConnectionAssignment>> GetAllSiteDataConnectionAssignmentsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SiteDataConnectionAssignments
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Template>> GetTemplateTreeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Templates
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Include(t => t.Compositions)
|
||||
.OrderBy(t => t.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Instance>> GetInstancesFilteredAsync(
|
||||
int? siteId = null,
|
||||
int? templateId = null,
|
||||
string? searchTerm = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.Instances.AsNoTracking().AsQueryable();
|
||||
|
||||
if (siteId.HasValue)
|
||||
query = query.Where(i => i.SiteId == siteId.Value);
|
||||
|
||||
if (templateId.HasValue)
|
||||
query = query.Where(i => i.TemplateId == templateId.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
query = query.Where(i => i.UniqueName.Contains(searchTerm));
|
||||
|
||||
return await query
|
||||
.OrderBy(i => i.UniqueName)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DeploymentRecord>> GetRecentDeploymentsAsync(int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.DeploymentRecords
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(d => d.DeployedAt)
|
||||
.Take(count)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Area>> GetAreaTreeBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Areas
|
||||
.AsNoTracking()
|
||||
.Where(a => a.SiteId == siteId)
|
||||
.Include(a => a.Children)
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<AuditLogEntry> Entries, int TotalCount)> GetAuditLogEntriesAsync(
|
||||
string? user = null,
|
||||
string? entityType = null,
|
||||
string? action = null,
|
||||
DateTimeOffset? from = null,
|
||||
DateTimeOffset? to = null,
|
||||
string? entityId = null,
|
||||
string? entityName = null,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.AuditLogEntries.AsNoTracking().AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user))
|
||||
query = query.Where(a => a.User == user);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityType))
|
||||
query = query.Where(a => a.EntityType == entityType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(action))
|
||||
query = query.Where(a => a.Action == action);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(a => a.Timestamp >= from.Value);
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.Timestamp <= to.Value);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityId))
|
||||
query = query.Where(a => a.EntityId == entityId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityName))
|
||||
query = query.Where(a => a.EntityName.Contains(entityName));
|
||||
|
||||
var totalCount = await query.CountAsync(cancellationToken);
|
||||
|
||||
var entries = await query
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return (entries, totalCount);
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Security;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class SecurityRepository : ISecurityRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public SecurityRepository(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
// LdapGroupMapping
|
||||
|
||||
public async Task<LdapGroupMapping?> GetMappingByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LdapGroupMapping>> GetAllMappingsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.LdapGroupMappings.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LdapGroupMapping>> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.LdapGroupMappings
|
||||
.Where(m => m.Role == role)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.LdapGroupMappings.AddAsync(mapping, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.LdapGroupMappings.Update(mapping);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var mapping = await _context.LdapGroupMappings.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (mapping != null)
|
||||
{
|
||||
_context.LdapGroupMappings.Remove(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// SiteScopeRule
|
||||
|
||||
public async Task<SiteScopeRule?> GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SiteScopeRule>> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SiteScopeRules
|
||||
.Where(r => r.LdapGroupMappingId == ldapGroupMappingId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _context.SiteScopeRules.AddAsync(rule, cancellationToken);
|
||||
}
|
||||
|
||||
public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.SiteScopeRules.Update(rule);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rule = await _context.SiteScopeRules.FindAsync(new object[] { id }, cancellationToken);
|
||||
if (rule != null)
|
||||
{
|
||||
_context.SiteScopeRules.Remove(rule);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,16 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Security;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase;
|
||||
|
||||
public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
{
|
||||
public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
// Templates
|
||||
public DbSet<Template> Templates => Set<Template>();
|
||||
public DbSet<TemplateAttribute> TemplateAttributes => Set<TemplateAttribute>();
|
||||
public DbSet<TemplateAlarm> TemplateAlarms => Set<TemplateAlarm>();
|
||||
public DbSet<TemplateScript> TemplateScripts => Set<TemplateScript>();
|
||||
public DbSet<TemplateComposition> TemplateCompositions => Set<TemplateComposition>();
|
||||
|
||||
// Instances
|
||||
public DbSet<Instance> Instances => Set<Instance>();
|
||||
public DbSet<InstanceAttributeOverride> InstanceAttributeOverrides => Set<InstanceAttributeOverride>();
|
||||
public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
|
||||
public DbSet<Area> Areas => Set<Area>();
|
||||
|
||||
// Sites
|
||||
public DbSet<Site> Sites => Set<Site>();
|
||||
public DbSet<DataConnection> DataConnections => Set<DataConnection>();
|
||||
public DbSet<SiteDataConnectionAssignment> SiteDataConnectionAssignments => Set<SiteDataConnectionAssignment>();
|
||||
|
||||
// Deployment
|
||||
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();
|
||||
public DbSet<SystemArtifactDeploymentRecord> SystemArtifactDeploymentRecords => Set<SystemArtifactDeploymentRecord>();
|
||||
|
||||
// External Systems
|
||||
public DbSet<ExternalSystemDefinition> ExternalSystemDefinitions => Set<ExternalSystemDefinition>();
|
||||
public DbSet<ExternalSystemMethod> ExternalSystemMethods => Set<ExternalSystemMethod>();
|
||||
public DbSet<DatabaseConnectionDefinition> DatabaseConnectionDefinitions => Set<DatabaseConnectionDefinition>();
|
||||
|
||||
// Notifications
|
||||
public DbSet<NotificationList> NotificationLists => Set<NotificationList>();
|
||||
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
|
||||
public DbSet<SmtpConfiguration> SmtpConfigurations => Set<SmtpConfiguration>();
|
||||
|
||||
// Scripts
|
||||
public DbSet<SharedScript> SharedScripts => Set<SharedScript>();
|
||||
|
||||
// Security
|
||||
public DbSet<LdapGroupMapping> LdapGroupMappings => Set<LdapGroupMapping>();
|
||||
public DbSet<SiteScopeRule> SiteScopeRules => Set<SiteScopeRule>();
|
||||
|
||||
// Inbound API
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
public DbSet<ApiMethod> ApiMethods => Set<ApiMethod>();
|
||||
|
||||
// Audit
|
||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||
|
||||
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaLinkDbContext).Assembly);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,41 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.ConfigurationDatabase.Services;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the ScadaLinkDbContext with the provided SQL Server connection string.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
|
||||
{
|
||||
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||
options.UseSqlServer(connectionString));
|
||||
|
||||
services.AddScoped<ISecurityRepository, SecurityRepository>();
|
||||
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<ScadaLinkDbContext>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the ScadaLinkDbContext with no connection string (for backward compatibility / Phase 0 stubs).
|
||||
/// This overload is a no-op placeholder; callers should migrate to the overload that accepts a connection string.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
// Retained for backward compatibility during migration.
|
||||
// Site nodes do not use the configuration database, so this is intentionally a no-op.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Services;
|
||||
|
||||
public class AuditService : IAuditService
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public AuditService(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task LogAsync(
|
||||
string user,
|
||||
string action,
|
||||
string entityType,
|
||||
string entityId,
|
||||
string entityName,
|
||||
object? afterState,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = new AuditLogEntry(user, action, entityType, entityId, entityName)
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
AfterStateJson = afterState != null
|
||||
? JsonSerializer.Serialize(afterState)
|
||||
: null
|
||||
};
|
||||
|
||||
// Add to change tracker only — caller is responsible for calling SaveChangesAsync
|
||||
// to ensure atomicity with the entity change.
|
||||
await _context.AuditLogEntries.AddAsync(entry, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that manages the Akka.NET actor system lifecycle.
|
||||
/// Creates the actor system on start, registers actors, and triggers
|
||||
/// CoordinatedShutdown on stop.
|
||||
/// </summary>
|
||||
public class AkkaHostedService : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NodeOptions _nodeOptions;
|
||||
private readonly ClusterOptions _clusterOptions;
|
||||
private readonly ILogger<AkkaHostedService> _logger;
|
||||
private ActorSystem? _actorSystem;
|
||||
|
||||
public AkkaHostedService(
|
||||
IServiceProvider serviceProvider,
|
||||
IOptions<NodeOptions> nodeOptions,
|
||||
IOptions<ClusterOptions> clusterOptions,
|
||||
ILogger<AkkaHostedService> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_nodeOptions = nodeOptions.Value;
|
||||
_clusterOptions = clusterOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor system once started. Null before StartAsync completes.
|
||||
/// </summary>
|
||||
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<ILoggerFactory>();
|
||||
var dlmLogger = loggerFactory.CreateLogger<DeadLetterMonitorActor>();
|
||||
_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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ScadaLink.Host.Actors;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to Akka.NET dead letter events, logs them, and tracks count
|
||||
/// for health monitoring integration.
|
||||
/// </summary>
|
||||
public class DeadLetterMonitorActor : ReceiveActor
|
||||
{
|
||||
private long _deadLetterCount;
|
||||
|
||||
public DeadLetterMonitorActor(ILogger<DeadLetterMonitorActor> logger)
|
||||
{
|
||||
Receive<DeadLetter>(dl =>
|
||||
{
|
||||
_deadLetterCount++;
|
||||
logger.LogWarning(
|
||||
"Dead letter: {MessageType} from {Sender} to {Recipient}",
|
||||
dl.Message.GetType().Name,
|
||||
dl.Sender,
|
||||
dl.Recipient);
|
||||
});
|
||||
|
||||
Receive<GetDeadLetterCount>(_ => 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message to request the current dead letter count.
|
||||
/// </summary>
|
||||
public sealed class GetDeadLetterCount
|
||||
{
|
||||
public static readonly GetDeadLetterCount Instance = new();
|
||||
private GetDeadLetterCount() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing the current dead letter count.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterCountResponse(long Count);
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace ScadaLink.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Health check that verifies Akka.NET cluster membership.
|
||||
/// Initially returns healthy; will be refined when Akka cluster integration is complete.
|
||||
/// </summary>
|
||||
public class AkkaClusterHealthCheck : IHealthCheck
|
||||
{
|
||||
public Task<HealthCheckResult> 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."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Health check that verifies database connectivity for Central nodes.
|
||||
/// </summary>
|
||||
public class DatabaseHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ScadaLinkDbContext _dbContext;
|
||||
|
||||
public DatabaseHealthCheck(ScadaLinkDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
-55
@@ -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,71 +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();
|
||||
builder.Services.AddConfigurationDatabase();
|
||||
|
||||
// Options binding
|
||||
BindSharedOptions(builder.Services, builder.Configuration);
|
||||
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
||||
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
|
||||
|
||||
var app = builder.Build();
|
||||
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) =>
|
||||
if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Shared components
|
||||
services.AddClusterInfrastructure();
|
||||
services.AddCommunication();
|
||||
services.AddHealthMonitoring();
|
||||
services.AddExternalSystemGateway();
|
||||
services.AddNotificationService();
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddConfiguration(configuration);
|
||||
|
||||
// Site-only components
|
||||
services.AddSiteRuntime();
|
||||
services.AddDataConnectionLayer();
|
||||
services.AddStoreAndForward();
|
||||
services.AddSiteEventLogging();
|
||||
// WP-14: Serilog
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// WP-17: Windows Service support (no-op when not running as a Windows Service)
|
||||
builder.Host.UseWindowsService();
|
||||
|
||||
// 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);
|
||||
|
||||
// WP-12: Health checks for readiness gating
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<DatabaseHealthCheck>("database")
|
||||
.AddCheck<AkkaClusterHealthCheck>("akka-cluster");
|
||||
|
||||
// WP-13: Akka.NET bootstrap via hosted service
|
||||
builder.Services.AddSingleton<AkkaHostedService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
||||
|
||||
// Options binding
|
||||
BindSharedOptions(services, context.Configuration);
|
||||
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
|
||||
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
|
||||
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
|
||||
});
|
||||
BindSharedOptions(builder.Services, builder.Configuration);
|
||||
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
||||
builder.Services.Configure<InboundApiOptions>(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<ScadaLinkDbContext>();
|
||||
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<AkkaHostedService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
||||
|
||||
// Options binding
|
||||
BindSharedOptions(services, context.Configuration);
|
||||
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
|
||||
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
|
||||
services.Configure<SiteEventLogOptions>(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)
|
||||
|
||||
@@ -7,6 +7,21 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.Cluster.Hosting" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Hosting" Version="1.5.62" />
|
||||
<PackageReference Include="Akka.Remote.Hosting" Version="1.5.62" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace ScadaLink.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Validates required configuration before Akka.NET actor system creation.
|
||||
/// Runs early in startup to fail fast with clear error messages.
|
||||
/// </summary>
|
||||
public static class StartupValidator
|
||||
{
|
||||
public static void Validate(IConfiguration configuration)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
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<List<string>>();
|
||||
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}"))}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string RequireAdmin = "RequireAdmin";
|
||||
public const string RequireDesign = "RequireDesign";
|
||||
public const string RequireDeployment = "RequireDeployment";
|
||||
|
||||
public static IServiceCollection AddScadaLinkAuthorization(this IServiceCollection services)
|
||||
{
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(RequireAdmin, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, "Admin"));
|
||||
|
||||
options.AddPolicy(RequireDesign, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, "Design"));
|
||||
|
||||
options.AddPolicy(RequireDeployment, policy =>
|
||||
policy.RequireClaim(JwtTokenService.RoleClaimType, "Deployment"));
|
||||
});
|
||||
|
||||
services.AddSingleton<IAuthorizationHandler, SiteScopeAuthorizationHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
public class JwtTokenService
|
||||
{
|
||||
private readonly SecurityOptions _options;
|
||||
private readonly ILogger<JwtTokenService> _logger;
|
||||
|
||||
public const string DisplayNameClaimType = "DisplayName";
|
||||
public const string UsernameClaimType = "Username";
|
||||
public const string RoleClaimType = "Role";
|
||||
public const string SiteIdClaimType = "SiteId";
|
||||
public const string LastActivityClaimType = "LastActivity";
|
||||
|
||||
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string GenerateToken(
|
||||
string displayName,
|
||||
string username,
|
||||
IReadOnlyList<string> roles,
|
||||
IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(DisplayNameClaimType, displayName),
|
||||
new(UsernameClaimType, username),
|
||||
new(LastActivityClaimType, DateTimeOffset.UtcNow.ToString("o"))
|
||||
};
|
||||
|
||||
foreach (var role in roles)
|
||||
{
|
||||
claims.Add(new Claim(RoleClaimType, role));
|
||||
}
|
||||
|
||||
if (permittedSiteIds != null)
|
||||
{
|
||||
foreach (var siteId in permittedSiteIds)
|
||||
{
|
||||
claims.Add(new Claim(SiteIdClaimType, siteId));
|
||||
}
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public ClaimsPrincipal? ValidateToken(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(token, validationParameters, out _);
|
||||
return principal;
|
||||
}
|
||||
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Token validation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShouldRefresh(ClaimsPrincipal principal)
|
||||
{
|
||||
var expClaim = principal.FindFirst("exp");
|
||||
if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
|
||||
return false;
|
||||
|
||||
var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
|
||||
var remaining = expiry - DateTimeOffset.UtcNow;
|
||||
|
||||
return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
|
||||
}
|
||||
|
||||
public bool IsIdleTimedOut(ClaimsPrincipal principal)
|
||||
{
|
||||
var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
|
||||
if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
|
||||
return true;
|
||||
|
||||
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
|
||||
{
|
||||
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
|
||||
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
|
||||
|
||||
if (displayName == null || username == null)
|
||||
{
|
||||
_logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims");
|
||||
return null;
|
||||
}
|
||||
|
||||
return GenerateToken(displayName, username, currentRoles, permittedSiteIds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
public record LdapAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
string? Username,
|
||||
IReadOnlyList<string>? Groups,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,148 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
public class LdapAuthService
|
||||
{
|
||||
private readonly SecurityOptions _options;
|
||||
private readonly ILogger<LdapAuthService> _logger;
|
||||
|
||||
public LdapAuthService(IOptions<SecurityOptions> options, ILogger<LdapAuthService> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new LdapAuthResult(false, null, null, null, "Username is required.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new LdapAuthResult(false, null, null, null, "Password is required.");
|
||||
|
||||
// Enforce TLS unless explicitly allowed for dev/test
|
||||
if (!_options.LdapUseTls && !_options.AllowInsecureLdap)
|
||||
{
|
||||
return new LdapAuthResult(false, null, null, null,
|
||||
"Insecure LDAP connections are not allowed. Enable TLS or set AllowInsecureLdap for dev/test.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = new LdapConnection();
|
||||
|
||||
if (_options.LdapUseTls)
|
||||
{
|
||||
connection.SecureSocketLayer = true;
|
||||
}
|
||||
|
||||
await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct);
|
||||
|
||||
if (_options.LdapUseTls && !connection.SecureSocketLayer)
|
||||
{
|
||||
await Task.Run(() => connection.StartTls(), ct);
|
||||
}
|
||||
|
||||
// Direct bind with user credentials
|
||||
var bindDn = BuildBindDn(username);
|
||||
await Task.Run(() => connection.Bind(bindDn, password), ct);
|
||||
|
||||
// Query for user attributes and group memberships
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var searchResults = await Task.Run(() =>
|
||||
connection.Search(
|
||||
_options.LdapSearchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
searchFilter,
|
||||
new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute },
|
||||
false), ct);
|
||||
|
||||
while (searchResults.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = searchResults.Next();
|
||||
var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute);
|
||||
if (dnAttr != null)
|
||||
displayName = dnAttr.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute);
|
||||
if (groupAttr != null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
{
|
||||
groups.Add(ExtractCn(groupDn));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
// No more results
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query LDAP attributes for user {Username}; authentication succeeded but group lookup failed", username);
|
||||
// Auth succeeded even if attribute lookup failed
|
||||
}
|
||||
|
||||
connection.Disconnect();
|
||||
|
||||
return new LdapAuthResult(true, displayName, username, groups, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
|
||||
return new LdapAuthResult(false, null, username, null, "Invalid username or password.");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error during LDAP authentication for user {Username}", username);
|
||||
return new LdapAuthResult(false, null, username, null, "An unexpected error occurred during authentication.");
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildBindDn(string username)
|
||||
{
|
||||
// If username already looks like a DN, use it as-is
|
||||
if (username.Contains('='))
|
||||
return username;
|
||||
|
||||
// Build DN from username and search base
|
||||
return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{_options.LdapSearchBase}";
|
||||
}
|
||||
|
||||
private static string EscapeLdapFilter(string input)
|
||||
{
|
||||
return input
|
||||
.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
}
|
||||
|
||||
private static string ExtractCn(string dn)
|
||||
{
|
||||
// Extract CN from a DN like "cn=GroupName,dc=example,dc=com"
|
||||
if (dn.StartsWith("cn=", StringComparison.OrdinalIgnoreCase) ||
|
||||
dn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var commaIndex = dn.IndexOf(',');
|
||||
return commaIndex > 3 ? dn[3..commaIndex] : dn[3..];
|
||||
}
|
||||
return dn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
public class RoleMapper
|
||||
{
|
||||
private readonly ISecurityRepository _securityRepository;
|
||||
|
||||
public RoleMapper(ISecurityRepository securityRepository)
|
||||
{
|
||||
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
|
||||
}
|
||||
|
||||
public async Task<RoleMappingResult> MapGroupsToRolesAsync(
|
||||
IReadOnlyList<string> ldapGroups,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var allMappings = await _securityRepository.GetAllMappingsAsync(ct);
|
||||
|
||||
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var permittedSiteIds = new HashSet<string>();
|
||||
var hasDeploymentRole = false;
|
||||
var hasDeploymentWithScopeRules = false;
|
||||
|
||||
foreach (var mapping in allMappings)
|
||||
{
|
||||
// Match LDAP group names (case-insensitive)
|
||||
if (!ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
matchedRoles.Add(mapping.Role);
|
||||
|
||||
if (mapping.Role.Equals("Deployment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hasDeploymentRole = true;
|
||||
|
||||
// Check for site scope rules
|
||||
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
|
||||
if (scopeRules.Count > 0)
|
||||
{
|
||||
hasDeploymentWithScopeRules = true;
|
||||
foreach (var rule in scopeRules)
|
||||
{
|
||||
permittedSiteIds.Add(rule.SiteId.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// System-wide deployment: user has Deployment role but no site scope rules restrict them
|
||||
var isSystemWide = hasDeploymentRole && !hasDeploymentWithScopeRules;
|
||||
|
||||
return new RoleMappingResult(
|
||||
matchedRoles.ToList(),
|
||||
permittedSiteIds.ToList(),
|
||||
isSystemWide);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
public record RoleMappingResult(
|
||||
IReadOnlyList<string> Roles,
|
||||
IReadOnlyList<string> PermittedSiteIds,
|
||||
bool IsSystemWideDeployment);
|
||||
@@ -10,6 +10,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.5" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.11.0" />
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,7 +5,34 @@ public class SecurityOptions
|
||||
public string LdapServer { get; set; } = string.Empty;
|
||||
public int LdapPort { get; set; } = 389;
|
||||
public bool LdapUseTls { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Allow insecure (non-TLS) LDAP connections. ONLY for dev/test with GLAuth.
|
||||
/// Must be false in production.
|
||||
/// </summary>
|
||||
public bool AllowInsecureLdap { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Base DN for LDAP searches (e.g., "dc=example,dc=com").
|
||||
/// </summary>
|
||||
public string LdapSearchBase { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP attribute that contains the user's display name.
|
||||
/// </summary>
|
||||
public string LdapDisplayNameAttribute { get; set; } = "cn";
|
||||
|
||||
/// <summary>
|
||||
/// LDAP attribute that contains group membership.
|
||||
/// </summary>
|
||||
public string LdapGroupAttribute { get; set; } = "memberOf";
|
||||
|
||||
public string JwtSigningKey { get; set; } = string.Empty;
|
||||
public int JwtExpiryMinutes { get; set; } = 15;
|
||||
public int IdleTimeoutMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Minutes before token expiry to trigger refresh.
|
||||
/// </summary>
|
||||
public int JwtRefreshThresholdMinutes { get; set; } = 5;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSecurity(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.AddScoped<LdapAuthService>();
|
||||
services.AddScoped<JwtTokenService>();
|
||||
services.AddScoped<RoleMapper>();
|
||||
services.AddScadaLinkAuthorization();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization requirement for site-scoped deployment operations.
|
||||
/// </summary>
|
||||
public class SiteScopeRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public string TargetSiteId { get; }
|
||||
|
||||
public SiteScopeRequirement(string targetSiteId)
|
||||
{
|
||||
TargetSiteId = targetSiteId ?? throw new ArgumentNullException(nameof(targetSiteId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that a user with the Deployment role is permitted to operate on the target site.
|
||||
/// Users with Deployment role and no SiteId claims are system-wide deployers.
|
||||
/// Users with SiteId claims are only permitted on those specific sites.
|
||||
/// </summary>
|
||||
public class SiteScopeAuthorizationHandler : AuthorizationHandler<SiteScopeRequirement>
|
||||
{
|
||||
protected override Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
SiteScopeRequirement requirement)
|
||||
{
|
||||
// Must have Deployment role
|
||||
var hasDeploymentRole = context.User.HasClaim(JwtTokenService.RoleClaimType, "Deployment");
|
||||
if (!hasDeploymentRole)
|
||||
{
|
||||
return Task.CompletedTask; // Fail — no Deployment role
|
||||
}
|
||||
|
||||
var siteIdClaims = context.User.FindAll(JwtTokenService.SiteIdClaimType).ToList();
|
||||
|
||||
if (siteIdClaims.Count == 0)
|
||||
{
|
||||
// No site scope restrictions — system-wide deployer
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else if (siteIdClaims.Any(c => c.Value == requirement.TargetSiteId))
|
||||
{
|
||||
// User is permitted on this specific site
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
// Otherwise, silently fail (not authorized for this site)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Services;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class AuditServiceTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
public AuditServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
_auditService = new AuditService(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_CreatesAuditEntry_CommittedWithEntityChange()
|
||||
{
|
||||
// Simulate entity change + audit in same transaction
|
||||
var template = new Template("TestTemplate");
|
||||
_context.Templates.Add(template);
|
||||
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "TestTemplate",
|
||||
new { Name = "TestTemplate" });
|
||||
|
||||
// Single SaveChangesAsync commits both
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.Equal("admin", audit.User);
|
||||
Assert.Equal("Create", audit.Action);
|
||||
Assert.Equal("Template", audit.EntityType);
|
||||
Assert.NotNull(audit.AfterStateJson);
|
||||
|
||||
// Template also committed
|
||||
Assert.Single(await _context.Templates.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_Rollback_BothChangeAndAuditRolledBack()
|
||||
{
|
||||
// Use a separate context to simulate rollback via not calling SaveChanges
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite(_context.Database.GetDbConnection())
|
||||
.Options;
|
||||
|
||||
using var context2 = new ScadaLinkDbContext(options);
|
||||
var auditService2 = new AuditService(context2);
|
||||
|
||||
var template = new Template("RollbackTemplate");
|
||||
context2.Templates.Add(template);
|
||||
await auditService2.LogAsync("admin", "Create", "Template", "99", "RollbackTemplate",
|
||||
new { Name = "RollbackTemplate" });
|
||||
|
||||
// Intentionally do NOT call SaveChangesAsync — simulates rollback
|
||||
// Verify nothing persisted
|
||||
Assert.Empty(await _context.AuditLogEntries.Where(a => a.EntityName == "RollbackTemplate").ToListAsync());
|
||||
Assert.Empty(await _context.Templates.Where(t => t.Name == "RollbackTemplate").ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_SerializesAfterStateAsJson()
|
||||
{
|
||||
var state = new { Name = "Test", Value = 42, Nested = new { Prop = "inner" } };
|
||||
await _auditService.LogAsync("admin", "Create", "Entity", "1", "Test", state);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.NotNull(audit.AfterStateJson);
|
||||
|
||||
var deserialized = JsonSerializer.Deserialize<JsonElement>(audit.AfterStateJson!);
|
||||
Assert.Equal("Test", deserialized.GetProperty("Name").GetString());
|
||||
Assert.Equal(42, deserialized.GetProperty("Value").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_NullAfterState_ForDeletes()
|
||||
{
|
||||
await _auditService.LogAsync("admin", "Delete", "Template", "1", "DeletedTemplate", null);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
Assert.Null(audit.AfterStateJson);
|
||||
Assert.Equal("Delete", audit.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogAsync_SetsTimestampToUtcNow()
|
||||
{
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
await _auditService.LogAsync("admin", "Create", "Template", "1", "T1", new { });
|
||||
await _context.SaveChangesAsync();
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
var audit = await _context.AuditLogEntries.SingleAsync();
|
||||
// Allow 2 second tolerance for SQLite precision
|
||||
Assert.True(audit.Timestamp >= before.AddSeconds(-2));
|
||||
Assert.True(audit.Timestamp <= after.AddSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditService_IsAppendOnly_NoUpdateOrDeleteMethods()
|
||||
{
|
||||
// Verify IAuditService only exposes LogAsync — no update/delete
|
||||
var methods = typeof(IAuditService).GetMethods();
|
||||
Assert.Single(methods, m => m.Name == "LogAsync");
|
||||
Assert.DoesNotContain(methods, m => m.Name.Contains("Update", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.DoesNotContain(methods, m => m.Name.Contains("Delete", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A test-specific DbContext that uses an explicit ConcurrencyToken on DeploymentRecord
|
||||
/// (as opposed to SQL Server's IsRowVersion()) so that SQLite can enforce concurrency.
|
||||
/// In production, the SQL Server RowVersion provides this automatically.
|
||||
/// </summary>
|
||||
public class ConcurrencyTestDbContext : ScadaLinkDbContext
|
||||
{
|
||||
public ConcurrencyTestDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options) { }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Replace the SQL Server RowVersion with an explicit concurrency token for SQLite
|
||||
// Remove the shadow RowVersion property and add a visible ConcurrencyStamp
|
||||
modelBuilder.Entity<DeploymentRecord>(builder =>
|
||||
{
|
||||
// The shadow RowVersion property from the base config doesn't work in SQLite.
|
||||
// Instead, use Status as a concurrency token for the test.
|
||||
builder.Property(d => d.Status).IsConcurrencyToken();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class ConcurrencyTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public ConcurrencyTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
File.Delete(_dbPath);
|
||||
}
|
||||
|
||||
private ScadaLinkDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite($"DataSource={_dbPath}")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
return new ConcurrencyTestDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentRecord_OptimisticConcurrency_SecondUpdateThrows()
|
||||
{
|
||||
// Setup: create necessary entities
|
||||
using (var setupCtx = CreateContext())
|
||||
{
|
||||
await setupCtx.Database.EnsureCreatedAsync();
|
||||
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
setupCtx.Sites.Add(site);
|
||||
setupCtx.Templates.Add(template);
|
||||
await setupCtx.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("I1")
|
||||
{
|
||||
SiteId = site.Id,
|
||||
TemplateId = template.Id,
|
||||
State = InstanceState.Enabled
|
||||
};
|
||||
setupCtx.Instances.Add(instance);
|
||||
await setupCtx.SaveChangesAsync();
|
||||
|
||||
var record = new DeploymentRecord("deploy-concurrent", "admin")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
Status = DeploymentStatus.Pending,
|
||||
DeployedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
setupCtx.DeploymentRecords.Add(record);
|
||||
await setupCtx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Load the same record in two separate contexts
|
||||
using var ctx1 = CreateContext();
|
||||
using var ctx2 = CreateContext();
|
||||
|
||||
var record1 = await ctx1.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent");
|
||||
var record2 = await ctx2.DeploymentRecords.SingleAsync(d => d.DeploymentId == "deploy-concurrent");
|
||||
|
||||
// Both loaded Status = Pending. First context updates and saves successfully.
|
||||
record1.Status = DeploymentStatus.Success;
|
||||
record1.CompletedAt = DateTimeOffset.UtcNow;
|
||||
await ctx1.SaveChangesAsync();
|
||||
|
||||
// Second context tries to update the same record from the stale "Pending" state — should throw
|
||||
// because the Status concurrency token has changed from Pending to Success
|
||||
record2.Status = DeploymentStatus.Failed;
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(
|
||||
() => ctx2.SaveChangesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Template_NoOptimisticConcurrency_LastWriteWins()
|
||||
{
|
||||
// Setup
|
||||
using (var setupCtx = CreateContext())
|
||||
{
|
||||
await setupCtx.Database.EnsureCreatedAsync();
|
||||
|
||||
var template = new Template("ConcurrentTemplate")
|
||||
{
|
||||
Description = "Original"
|
||||
};
|
||||
setupCtx.Templates.Add(template);
|
||||
await setupCtx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Load in two contexts
|
||||
using var ctx1 = CreateContext();
|
||||
using var ctx2 = CreateContext();
|
||||
|
||||
var template1 = await ctx1.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate");
|
||||
var template2 = await ctx2.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate");
|
||||
|
||||
// First update
|
||||
template1.Description = "First update";
|
||||
await ctx1.SaveChangesAsync();
|
||||
|
||||
// Second update — should succeed (last-write-wins, no concurrency token)
|
||||
template2.Description = "Second update";
|
||||
await ctx2.SaveChangesAsync(); // Should NOT throw
|
||||
|
||||
// Verify last write won
|
||||
using var verifyCtx = CreateContext();
|
||||
var loaded = await verifyCtx.Templates.SingleAsync(t => t.Name == "ConcurrentTemplate");
|
||||
Assert.Equal("Second update", loaded.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentRecord_HasRowVersionConfigured()
|
||||
{
|
||||
// Verify the production configuration has a RowVersion shadow property
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
using var context = new ScadaLinkDbContext(options);
|
||||
context.Database.OpenConnection();
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
var entityType = context.Model.FindEntityType(typeof(DeploymentRecord))!;
|
||||
var rowVersion = entityType.FindProperty("RowVersion");
|
||||
Assert.NotNull(rowVersion);
|
||||
Assert.True(rowVersion!.IsConcurrencyToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class DataProtectionTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DataProtectionTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scadalink_dp_test_{Guid.NewGuid()}.db");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
File.Delete(_dbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SharedDataProtection_ProtectAndUnprotect_AcrossContainers()
|
||||
{
|
||||
var connectionString = $"DataSource={_dbPath}";
|
||||
|
||||
// Create the database schema
|
||||
var setupOptions = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite(connectionString)
|
||||
.Options;
|
||||
using (var setupCtx = new ScadaLinkDbContext(setupOptions))
|
||||
{
|
||||
setupCtx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
// Container 1: protect some data
|
||||
var services1 = new ServiceCollection();
|
||||
services1.AddDbContext<ScadaLinkDbContext>(opt => opt.UseSqlite(connectionString));
|
||||
services1.AddDataProtection()
|
||||
.SetApplicationName("ScadaLink")
|
||||
.PersistKeysToDbContext<ScadaLinkDbContext>();
|
||||
|
||||
using var provider1 = services1.BuildServiceProvider();
|
||||
var protector1 = provider1.GetRequiredService<IDataProtectionProvider>()
|
||||
.CreateProtector("test-purpose");
|
||||
var protectedPayload = protector1.Protect("secret-data");
|
||||
|
||||
// Container 2: unprotect using the same DB (shared keys)
|
||||
var services2 = new ServiceCollection();
|
||||
services2.AddDbContext<ScadaLinkDbContext>(opt => opt.UseSqlite(connectionString));
|
||||
services2.AddDataProtection()
|
||||
.SetApplicationName("ScadaLink")
|
||||
.PersistKeysToDbContext<ScadaLinkDbContext>();
|
||||
|
||||
using var provider2 = services2.BuildServiceProvider();
|
||||
var protector2 = provider2.GetRequiredService<IDataProtectionProvider>()
|
||||
.CreateProtector("test-purpose");
|
||||
var unprotected = protector2.Unprotect(protectedPayload);
|
||||
|
||||
Assert.Equal("secret-data", unprotected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Security;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class SecurityRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly SecurityRepository _repository;
|
||||
|
||||
public SecurityRepositoryTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
_repository = new SecurityRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMapping_AndGetById_ReturnsMapping()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("CN=Admins,DC=test", "Admin");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("CN=Admins,DC=test", loaded.LdapGroupName);
|
||||
Assert.Equal("Admin", loaded.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllMappings_ReturnsAll()
|
||||
{
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Admin"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Design"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
// +1 for seed data
|
||||
var all = await _repository.GetAllMappingsAsync();
|
||||
Assert.True(all.Count >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMappingsByRole_FiltersCorrectly()
|
||||
{
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Design"));
|
||||
await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployment"));
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var designMappings = await _repository.GetMappingsByRoleAsync("Design");
|
||||
Assert.Single(designMappings);
|
||||
Assert.Equal("Designers", designMappings[0].LdapGroupName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMapping_PersistsChange()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("OldGroup", "Admin");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
mapping.Role = "Design";
|
||||
await _repository.UpdateMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.Equal("Design", loaded!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteMapping_RemovesEntity()
|
||||
{
|
||||
var mapping = new LdapGroupMapping("ToDelete", "Admin");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteMappingAsync(mapping.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetMappingByIdAsync(mapping.Id);
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddScopeRule_AndGetForMapping()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Deployers", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rules = await _repository.GetScopeRulesForMappingAsync(mapping.Id);
|
||||
Assert.Single(rules);
|
||||
Assert.Equal(site.Id, rules[0].SiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScopeRuleById_ReturnsRule()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(mapping.Id, loaded.LdapGroupMappingId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateScopeRule_PersistsChange()
|
||||
{
|
||||
var site1 = new Site("Site1", "SITE-001");
|
||||
var site2 = new Site("Site2", "SITE-002");
|
||||
_context.Sites.AddRange(site1, site2);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
rule.SiteId = site2.Id;
|
||||
await _repository.UpdateScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id);
|
||||
Assert.Equal(site2.Id, loaded!.SiteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteScopeRule_RemovesEntity()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
var mapping = new LdapGroupMapping("Group", "Deployment");
|
||||
await _repository.AddMappingAsync(mapping);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
await _repository.AddScopeRuleAsync(rule);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
await _repository.DeleteScopeRuleAsync(rule.Id);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetScopeRuleByIdAsync(rule.Id);
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
}
|
||||
|
||||
public class CentralUiRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly CentralUiRepository _repository;
|
||||
|
||||
public CentralUiRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new CentralUiRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllSites_ReturnsOrderedByName()
|
||||
{
|
||||
_context.Sites.AddRange(
|
||||
new Site("Zulu", "Z-001"),
|
||||
new Site("Alpha", "A-001"));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var sites = await _repository.GetAllSitesAsync();
|
||||
Assert.Equal(2, sites.Count);
|
||||
Assert.Equal("Alpha", sites[0].Name);
|
||||
Assert.Equal("Zulu", sites[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesFiltered_BySiteId()
|
||||
{
|
||||
var site1 = new Site("Site1", "S-001");
|
||||
var site2 = new Site("Site2", "S-002");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.AddRange(site1, site2);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.AddRange(
|
||||
new Instance("Inst1") { SiteId = site1.Id, TemplateId = template.Id },
|
||||
new Instance("Inst2") { SiteId = site2.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instances = await _repository.GetInstancesFilteredAsync(siteId: site1.Id);
|
||||
Assert.Single(instances);
|
||||
Assert.Equal("Inst1", instances[0].UniqueName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetInstancesFiltered_BySearchTerm()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.Instances.AddRange(
|
||||
new Instance("PumpStation1") { SiteId = site.Id, TemplateId = template.Id },
|
||||
new Instance("TankLevel1") { SiteId = site.Id, TemplateId = template.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instances = await _repository.GetInstancesFilteredAsync(searchTerm: "Pump");
|
||||
Assert.Single(instances);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentDeployments_ReturnsInReverseChronological()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
var template = new Template("T1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
_context.DeploymentRecords.AddRange(
|
||||
new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-2) },
|
||||
new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) },
|
||||
new DeploymentRecord("d-003", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var recent = await _repository.GetRecentDeploymentsAsync(2);
|
||||
Assert.Equal(2, recent.Count);
|
||||
Assert.Equal("d-003", recent[0].DeploymentId);
|
||||
Assert.Equal("d-002", recent[1].DeploymentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByUser()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("user1", "Update", "Instance", "2", "I1") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(user: "admin");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(1, total);
|
||||
Assert.Equal("admin", entries[0].User);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByEntityType()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Create", "Instance", "2", "I1") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityType: "Template");
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(1, total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByActionType()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Delete", "Template", "2", "T2") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(action: "Delete");
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByTimeRange()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = now.AddHours(-5) },
|
||||
new AuditLogEntry("admin", "Update", "Template", "2", "T2") { Timestamp = now.AddHours(-1) });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(from: now.AddHours(-2));
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByEntityId()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "T1") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Create", "Template", "2", "T2") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityId: "1");
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_FiltersByEntityName()
|
||||
{
|
||||
_context.AuditLogEntries.AddRange(
|
||||
new AuditLogEntry("admin", "Create", "Template", "1", "PumpStation") { Timestamp = DateTimeOffset.UtcNow },
|
||||
new AuditLogEntry("admin", "Create", "Template", "2", "TankLevel") { Timestamp = DateTimeOffset.UtcNow });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (entries, total) = await _repository.GetAuditLogEntriesAsync(entityName: "Pump");
|
||||
Assert.Single(entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAuditLogEntries_ReverseChronologicalWithPagination()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
_context.AuditLogEntries.Add(new AuditLogEntry("admin", "Create", "Template", i.ToString(), $"T{i}")
|
||||
{
|
||||
Timestamp = now.AddMinutes(i)
|
||||
});
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var (page1, total) = await _repository.GetAuditLogEntriesAsync(page: 1, pageSize: 3);
|
||||
Assert.Equal(10, total);
|
||||
Assert.Equal(3, page1.Count);
|
||||
Assert.Equal("T9", page1[0].EntityName); // Most recent first
|
||||
|
||||
var (page2, _) = await _repository.GetAuditLogEntriesAsync(page: 2, pageSize: 3);
|
||||
Assert.Equal(3, page2.Count);
|
||||
Assert.Equal("T6", page2[0].EntityName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTemplateTree_IncludesChildren()
|
||||
{
|
||||
var template = new Template("TestTemplate");
|
||||
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Int32 });
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var tree = await _repository.GetTemplateTreeAsync();
|
||||
Assert.NotEmpty(tree);
|
||||
var loaded = tree.First(t => t.Name == "TestTemplate");
|
||||
Assert.Single(loaded.Attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAreaTree_ReturnsHierarchy()
|
||||
{
|
||||
var site = new Site("Site1", "S-001");
|
||||
_context.Sites.Add(site);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var parent = new Area("Building A") { SiteId = site.Id };
|
||||
_context.Areas.Add(parent);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var child = new Area("Floor 1") { SiteId = site.Id, ParentAreaId = parent.Id };
|
||||
_context.Areas.Add(child);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var areas = await _repository.GetAreaTreeBySiteIdAsync(site.Id);
|
||||
Assert.Equal(2, areas.Count);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -10,6 +10,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" 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" />
|
||||
@@ -21,6 +25,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class SeedDataTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public SeedDataTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedData_AdminMappingExists()
|
||||
{
|
||||
var adminMapping = await _context.LdapGroupMappings
|
||||
.SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins");
|
||||
|
||||
Assert.NotNull(adminMapping);
|
||||
Assert.Equal("Admin", adminMapping.Role);
|
||||
Assert.Equal(1, adminMapping.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test DbContext that maps DateTimeOffset to a sortable string format for SQLite.
|
||||
/// EF Core 10 SQLite provider does not support ORDER BY on DateTimeOffset columns.
|
||||
/// </summary>
|
||||
public class SqliteTestDbContext : ScadaLinkDbContext
|
||||
{
|
||||
public SqliteTestDbContext(DbContextOptions<ScadaLinkDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Convert DateTimeOffset to ISO 8601 string for SQLite so ORDER BY works
|
||||
var converter = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("o"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
|
||||
var nullableConverter = new ValueConverter<DateTimeOffset?, string?>(
|
||||
v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null,
|
||||
v => v != null ? DateTimeOffset.Parse(v) : null);
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
if (property.ClrType == typeof(DateTimeOffset))
|
||||
{
|
||||
property.SetValueConverter(converter);
|
||||
property.SetColumnType("TEXT");
|
||||
}
|
||||
else if (property.ClrType == typeof(DateTimeOffset?))
|
||||
{
|
||||
property.SetValueConverter(nullableConverter);
|
||||
property.SetColumnType("TEXT");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class SqliteTestHelper
|
||||
{
|
||||
public static ScadaLinkDbContext CreateInMemoryContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
var context = new SqliteTestDbContext(options);
|
||||
context.Database.OpenConnection();
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
public static ScadaLinkDbContext CreateFileContext(string dbPath)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite($"DataSource={dbPath}")
|
||||
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
var context = new SqliteTestDbContext(options);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,434 @@
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Entities.Instances;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Entities.Scripts;
|
||||
using ScadaLink.Commons.Entities.Security;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Commons.Entities.Templates;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
public class UnitTest1
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests;
|
||||
|
||||
public class DbContextTests : IDisposable
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public DbContextTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Schema_CreatesAllTables()
|
||||
{
|
||||
// Verify all DbSet tables exist by checking we can query them without error
|
||||
Assert.NotNull(_context.Templates);
|
||||
Assert.NotNull(_context.TemplateAttributes);
|
||||
Assert.NotNull(_context.TemplateAlarms);
|
||||
Assert.NotNull(_context.TemplateScripts);
|
||||
Assert.NotNull(_context.TemplateCompositions);
|
||||
Assert.NotNull(_context.Instances);
|
||||
Assert.NotNull(_context.InstanceAttributeOverrides);
|
||||
Assert.NotNull(_context.InstanceConnectionBindings);
|
||||
Assert.NotNull(_context.Areas);
|
||||
Assert.NotNull(_context.Sites);
|
||||
Assert.NotNull(_context.DataConnections);
|
||||
Assert.NotNull(_context.SiteDataConnectionAssignments);
|
||||
Assert.NotNull(_context.DeploymentRecords);
|
||||
Assert.NotNull(_context.SystemArtifactDeploymentRecords);
|
||||
Assert.NotNull(_context.ExternalSystemDefinitions);
|
||||
Assert.NotNull(_context.ExternalSystemMethods);
|
||||
Assert.NotNull(_context.DatabaseConnectionDefinitions);
|
||||
Assert.NotNull(_context.NotificationLists);
|
||||
Assert.NotNull(_context.NotificationRecipients);
|
||||
Assert.NotNull(_context.SmtpConfigurations);
|
||||
Assert.NotNull(_context.SharedScripts);
|
||||
Assert.NotNull(_context.LdapGroupMappings);
|
||||
Assert.NotNull(_context.SiteScopeRules);
|
||||
Assert.NotNull(_context.ApiKeys);
|
||||
Assert.NotNull(_context.ApiMethods);
|
||||
Assert.NotNull(_context.AuditLogEntries);
|
||||
|
||||
// Verify we can enumerate all tables (schema is valid)
|
||||
Assert.Empty(_context.Templates.ToList());
|
||||
Assert.Empty(_context.Sites.ToList());
|
||||
Assert.Empty(_context.Instances.ToList());
|
||||
Assert.Empty(_context.AuditLogEntries.ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_WithChildren_CascadeCreated()
|
||||
{
|
||||
var template = new Template("TestTemplate")
|
||||
{
|
||||
Description = "A test template"
|
||||
};
|
||||
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Int32 });
|
||||
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.ValueMatch, PriorityLevel = 1 });
|
||||
template.Scripts.Add(new TemplateScript("Script1", "return 42;"));
|
||||
|
||||
_context.Templates.Add(template);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Templates
|
||||
.Include(t => t.Attributes)
|
||||
.Include(t => t.Alarms)
|
||||
.Include(t => t.Scripts)
|
||||
.Single(t => t.Name == "TestTemplate");
|
||||
|
||||
Assert.Single(loaded.Attributes);
|
||||
Assert.Single(loaded.Alarms);
|
||||
Assert.Single(loaded.Scripts);
|
||||
Assert.Equal("Attr1", loaded.Attributes.First().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_Inheritance_SelfReference()
|
||||
{
|
||||
var parent = new Template("ParentTemplate");
|
||||
_context.Templates.Add(parent);
|
||||
_context.SaveChanges();
|
||||
|
||||
var child = new Template("ChildTemplate") { ParentTemplateId = parent.Id };
|
||||
_context.Templates.Add(child);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Templates.Single(t => t.Name == "ChildTemplate");
|
||||
Assert.Equal(parent.Id, loaded.ParentTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_Composition_CreatesRelationship()
|
||||
{
|
||||
var composedTemplate = new Template("ComposedTemplate");
|
||||
var parentTemplate = new Template("ParentTemplate");
|
||||
_context.Templates.AddRange(composedTemplate, parentTemplate);
|
||||
_context.SaveChanges();
|
||||
|
||||
parentTemplate.Compositions.Add(new TemplateComposition("Module1") { ComposedTemplateId = composedTemplate.Id });
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Templates
|
||||
.Include(t => t.Compositions)
|
||||
.Single(t => t.Name == "ParentTemplate");
|
||||
|
||||
Assert.Single(loaded.Compositions);
|
||||
Assert.Equal(composedTemplate.Id, loaded.Compositions.First().ComposedTemplateId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Instance_WithOverridesAndBindings()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
var template = new Template("Template1");
|
||||
var dataConn = new DataConnection("OpcConn", "OpcUa");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
_context.DataConnections.Add(dataConn);
|
||||
_context.SaveChanges();
|
||||
|
||||
var instance = new Instance("Instance1")
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
SiteId = site.Id,
|
||||
State = InstanceState.Enabled
|
||||
};
|
||||
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Attr1") { OverrideValue = "42" });
|
||||
instance.ConnectionBindings.Add(new InstanceConnectionBinding("TagPath") { DataConnectionId = dataConn.Id });
|
||||
_context.Instances.Add(instance);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Instances
|
||||
.Include(i => i.AttributeOverrides)
|
||||
.Include(i => i.ConnectionBindings)
|
||||
.Single(i => i.UniqueName == "Instance1");
|
||||
|
||||
Assert.Single(loaded.AttributeOverrides);
|
||||
Assert.Single(loaded.ConnectionBindings);
|
||||
Assert.Equal(InstanceState.Enabled, loaded.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentRecord_CreatesWithAllFields()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
var template = new Template("Template1");
|
||||
_context.Sites.Add(site);
|
||||
_context.Templates.Add(template);
|
||||
_context.SaveChanges();
|
||||
|
||||
var instance = new Instance("Instance1") { TemplateId = template.Id, SiteId = site.Id, State = InstanceState.Enabled };
|
||||
_context.Instances.Add(instance);
|
||||
_context.SaveChanges();
|
||||
|
||||
var record = new DeploymentRecord("deploy-001", "admin")
|
||||
{
|
||||
InstanceId = instance.Id,
|
||||
Status = DeploymentStatus.Success,
|
||||
RevisionHash = "abc123",
|
||||
DeployedAt = DateTimeOffset.UtcNow,
|
||||
CompletedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
_context.DeploymentRecords.Add(record);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.DeploymentRecords.Single(d => d.DeploymentId == "deploy-001");
|
||||
Assert.Equal(DeploymentStatus.Success, loaded.Status);
|
||||
Assert.Equal("abc123", loaded.RevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLogEntry_WritesAndQueries()
|
||||
{
|
||||
var entry = new AuditLogEntry("admin", "Create", "Template", "1", "TestTemplate")
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
AfterStateJson = "{\"name\":\"TestTemplate\"}"
|
||||
};
|
||||
_context.AuditLogEntries.Add(entry);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.AuditLogEntries.Single(a => a.User == "admin");
|
||||
Assert.Equal("Create", loaded.Action);
|
||||
Assert.Equal("Template", loaded.EntityType);
|
||||
Assert.NotNull(loaded.AfterStateJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalSystem_WithMethods()
|
||||
{
|
||||
var system = new ExternalSystemDefinition("ERP", "https://erp.example.com/api", "ApiKey")
|
||||
{
|
||||
MaxRetries = 3,
|
||||
RetryDelay = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
_context.ExternalSystemDefinitions.Add(system);
|
||||
_context.SaveChanges();
|
||||
|
||||
var method = new ExternalSystemMethod("GetOrder", "GET", "/orders/{id}")
|
||||
{
|
||||
ExternalSystemDefinitionId = system.Id
|
||||
};
|
||||
_context.ExternalSystemMethods.Add(method);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.ExternalSystemMethods.Where(m => m.ExternalSystemDefinitionId == system.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationList_WithRecipients()
|
||||
{
|
||||
var list = new NotificationList("Operators");
|
||||
list.Recipients.Add(new NotificationRecipient("John", "john@example.com"));
|
||||
list.Recipients.Add(new NotificationRecipient("Jane", "jane@example.com"));
|
||||
|
||||
_context.NotificationLists.Add(list);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.NotificationLists
|
||||
.Include(n => n.Recipients)
|
||||
.Single(n => n.Name == "Operators");
|
||||
|
||||
Assert.Equal(2, loaded.Recipients.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Security_LdapGroupMapping_WithSiteScopeRules()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
_context.SaveChanges();
|
||||
|
||||
var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Admin");
|
||||
_context.LdapGroupMappings.Add(mapping);
|
||||
_context.SaveChanges();
|
||||
|
||||
var rule = new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site.Id };
|
||||
_context.SiteScopeRules.Add(rule);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.SiteScopeRules.Where(r => r.LdapGroupMappingId == mapping.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InboundApi_ApiKeyAndMethod()
|
||||
{
|
||||
var key = new ApiKey("TestKey", "sk-test-123") { IsEnabled = true };
|
||||
var method = new ApiMethod("GetStatus", "return \"ok\";") { TimeoutSeconds = 30 };
|
||||
|
||||
_context.ApiKeys.Add(key);
|
||||
_context.ApiMethods.Add(method);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.ApiKeys.Where(k => k.Name == "TestKey"));
|
||||
Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Area_HierarchyWorks()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
_context.Sites.Add(site);
|
||||
_context.SaveChanges();
|
||||
|
||||
var parentArea = new Area("Building A") { SiteId = site.Id };
|
||||
_context.Areas.Add(parentArea);
|
||||
_context.SaveChanges();
|
||||
|
||||
var childArea = new Area("Floor 1") { SiteId = site.Id, ParentAreaId = parentArea.Id };
|
||||
_context.Areas.Add(childArea);
|
||||
_context.SaveChanges();
|
||||
|
||||
var loaded = _context.Areas
|
||||
.Include(a => a.Children)
|
||||
.Single(a => a.Name == "Building A");
|
||||
|
||||
Assert.Single(loaded.Children);
|
||||
Assert.Equal("Floor 1", loaded.Children.First().Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteDataConnectionAssignment_CreatesBothForeignKeys()
|
||||
{
|
||||
var site = new Site("Site1", "SITE-001");
|
||||
var conn = new DataConnection("OpcConn", "OpcUa");
|
||||
_context.Sites.Add(site);
|
||||
_context.DataConnections.Add(conn);
|
||||
_context.SaveChanges();
|
||||
|
||||
var assignment = new SiteDataConnectionAssignment { SiteId = site.Id, DataConnectionId = conn.Id };
|
||||
_context.SiteDataConnectionAssignments.Add(assignment);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.SiteDataConnectionAssignments.ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnumProperties_StoredAsStrings()
|
||||
{
|
||||
var template = new Template("EnumTest");
|
||||
template.Attributes.Add(new TemplateAttribute("Attr1") { DataType = DataType.Double });
|
||||
template.Alarms.Add(new TemplateAlarm("Alarm1") { TriggerType = AlarmTriggerType.RangeViolation, PriorityLevel = 1 });
|
||||
_context.Templates.Add(template);
|
||||
_context.SaveChanges();
|
||||
|
||||
// Query using raw SQL to verify string storage
|
||||
var attr = _context.TemplateAttributes.Single(a => a.Name == "Attr1");
|
||||
Assert.Equal(DataType.Double, attr.DataType);
|
||||
|
||||
var alarm = _context.TemplateAlarms.Single(a => a.Name == "Alarm1");
|
||||
Assert.Equal(AlarmTriggerType.RangeViolation, alarm.TriggerType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UniqueConstraint_Template_Name_Enforced()
|
||||
{
|
||||
_context.Templates.Add(new Template("Unique"));
|
||||
_context.SaveChanges();
|
||||
|
||||
_context.Templates.Add(new Template("Unique"));
|
||||
Assert.ThrowsAny<Exception>(() => _context.SaveChanges());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTimeOffset_MappedCorrectly()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = new AuditLogEntry("user", "Test", "Entity", "1", "Name") { Timestamp = now };
|
||||
_context.AuditLogEntries.Add(entry);
|
||||
_context.SaveChanges();
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
var loaded = _context.AuditLogEntries.Single();
|
||||
// SQLite has limited DateTimeOffset precision, but the round-trip should preserve the value within a second
|
||||
Assert.True(Math.Abs((loaded.Timestamp - now).TotalSeconds) < 1);
|
||||
}
|
||||
}
|
||||
|
||||
public class ServiceRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_WithConnectionString_RegistersDbContext()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddConfigurationDatabase("DataSource=:memory:");
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var context = provider.GetService<ScadaLinkDbContext>();
|
||||
|
||||
Assert.NotNull(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddConfigurationDatabase_NoArgs_DoesNotThrow()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddConfigurationDatabase();
|
||||
|
||||
// Should not register DbContext (no-op for backward compatibility)
|
||||
var provider = services.BuildServiceProvider();
|
||||
var context = provider.GetService<ScadaLinkDbContext>();
|
||||
Assert.Null(context);
|
||||
}
|
||||
}
|
||||
|
||||
public class MigrationHelperTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public MigrationHelperTests()
|
||||
{
|
||||
// Use SQLite with PendingModelChangesWarning suppressed because the migration
|
||||
// was generated for SQL Server and SQLite's model representation differs slightly.
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyOrValidate_ProductionMode_WithPendingMigrations_Throws()
|
||||
{
|
||||
// Database has no schema yet, so pending migrations exist.
|
||||
// The production path uses GetPendingMigrationsAsync which works cross-provider.
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => MigrationHelper.ApplyOrValidateMigrationsAsync(_context, isDevelopment: false));
|
||||
|
||||
Assert.Contains("pending migration", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MigrationExists_InitialCreate()
|
||||
{
|
||||
// Verify the InitialCreate migration is detected as pending
|
||||
var pending = _context.Database.GetPendingMigrations().ToList();
|
||||
Assert.Contains(pending, m => m.Contains("InitialCreate"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Configuration;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Tests for Akka.NET actor system bootstrap.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-16: Tests for CoordinatedShutdown configuration.
|
||||
/// Verifies no Environment.Exit calls exist in source and HOCON config is correct.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// WP-15: Tests for DeadLetterMonitorActor.
|
||||
/// </summary>
|
||||
public class DeadLetterMonitorTests : TestKit
|
||||
{
|
||||
private readonly ILogger<DeadLetterMonitorActor> _logger =
|
||||
NullLoggerFactory.Instance.CreateLogger<DeadLetterMonitorActor>();
|
||||
|
||||
[Fact]
|
||||
public void DeadLetterMonitor_StartsWithZeroCount()
|
||||
{
|
||||
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
|
||||
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
var response = ExpectMsg<DeadLetterCountResponse>();
|
||||
|
||||
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<DeadLetterCountResponse>();
|
||||
|
||||
// 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<DeadLetterCountResponse>();
|
||||
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<DeadLetterCountResponse>();
|
||||
|
||||
// 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<DeadLetterCountResponse>();
|
||||
Assert.Equal(5, response.Count);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-12: Tests for /health/ready endpoint.
|
||||
/// </summary>
|
||||
public class HealthCheckTests : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _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<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,19 @@ public class HostStartupTests : IDisposable
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["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);
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-14: Tests for Serilog structured logging with enriched properties.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory Serilog sink for testing.
|
||||
/// </summary>
|
||||
public class InMemorySink : Serilog.Core.ILogEventSink
|
||||
{
|
||||
public List<LogEvent> LogEvents { get; } = new();
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
LogEvents.Add(logEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: Tests for StartupValidator configuration validation.
|
||||
/// </summary>
|
||||
public class StartupValidatorTests
|
||||
{
|
||||
private static IConfiguration BuildConfig(Dictionary<string, string?> values)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> 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<string, string?> 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleErrors_AllReported()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
// Role is missing, hostname is missing, port is missing
|
||||
};
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Tests for Windows Service support.
|
||||
/// Verifies UseWindowsService() is called in Program.cs.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" 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" />
|
||||
@@ -21,6 +27,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.Security/ScadaLink.Security.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||
<ProjectReference Include="../../src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,10 +1,507 @@
|
||||
namespace ScadaLink.Security.Tests;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.Commons.Entities.Security;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||
using ScadaLink.Security;
|
||||
|
||||
public class UnitTest1
|
||||
namespace ScadaLink.Security.Tests;
|
||||
|
||||
#region WP-6: LdapAuthService Tests
|
||||
|
||||
public class LdapAuthServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
private static SecurityOptions CreateOptions(bool useTls = true, bool allowInsecure = false) => new()
|
||||
{
|
||||
LdapServer = "ldap.example.com",
|
||||
LdapPort = 636,
|
||||
LdapUseTls = useTls,
|
||||
AllowInsecureLdap = allowInsecure,
|
||||
LdapSearchBase = "dc=example,dc=com",
|
||||
JwtSigningKey = "test-key-that-is-long-enough-for-hmac-sha256-minimum"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_EmptyUsername_ReturnsFailed()
|
||||
{
|
||||
var service = new LdapAuthService(
|
||||
Options.Create(CreateOptions()),
|
||||
NullLogger<LdapAuthService>.Instance);
|
||||
|
||||
var result = await service.AuthenticateAsync("", "password");
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Username is required", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_EmptyPassword_ReturnsFailed()
|
||||
{
|
||||
var service = new LdapAuthService(
|
||||
Options.Create(CreateOptions()),
|
||||
NullLogger<LdapAuthService>.Instance);
|
||||
|
||||
var result = await service.AuthenticateAsync("user", "");
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Password is required", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_InsecureLdapNotAllowed_ReturnsFailed()
|
||||
{
|
||||
var service = new LdapAuthService(
|
||||
Options.Create(CreateOptions(useTls: false, allowInsecure: false)),
|
||||
NullLogger<LdapAuthService>.Instance);
|
||||
|
||||
var result = await service.AuthenticateAsync("user", "password");
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Insecure LDAP", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_ConnectionFailure_ReturnsFailed()
|
||||
{
|
||||
// Point to a non-existent server — connection should fail
|
||||
var options = CreateOptions();
|
||||
options.LdapServer = "nonexistent.invalid";
|
||||
options.LdapPort = 9999;
|
||||
|
||||
var service = new LdapAuthService(
|
||||
Options.Create(options),
|
||||
NullLogger<LdapAuthService>.Instance);
|
||||
|
||||
var result = await service.AuthenticateAsync("user", "password");
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WP-7: JwtTokenService Tests
|
||||
|
||||
public class JwtTokenServiceTests
|
||||
{
|
||||
private static SecurityOptions CreateOptions() => new()
|
||||
{
|
||||
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
||||
JwtExpiryMinutes = 15,
|
||||
IdleTimeoutMinutes = 30,
|
||||
JwtRefreshThresholdMinutes = 5
|
||||
};
|
||||
|
||||
private static JwtTokenService CreateService(SecurityOptions? options = null)
|
||||
{
|
||||
return new JwtTokenService(
|
||||
Options.Create(options ?? CreateOptions()),
|
||||
NullLogger<JwtTokenService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_ContainsCorrectClaims()
|
||||
{
|
||||
var service = CreateService();
|
||||
var token = service.GenerateToken(
|
||||
"John Doe", "johnd",
|
||||
new[] { "Admin", "Design" },
|
||||
new[] { "1", "2" });
|
||||
|
||||
var principal = service.ValidateToken(token);
|
||||
Assert.NotNull(principal);
|
||||
|
||||
Assert.Equal("John Doe", principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value);
|
||||
Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
|
||||
|
||||
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
||||
Assert.Contains("Admin", roles);
|
||||
Assert.Contains("Design", roles);
|
||||
|
||||
var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
|
||||
Assert.Contains("1", siteIds);
|
||||
Assert.Contains("2", siteIds);
|
||||
|
||||
Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateToken_NullSiteIds_NoSiteIdClaims()
|
||||
{
|
||||
var service = CreateService();
|
||||
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
var principal = service.ValidateToken(token);
|
||||
|
||||
Assert.NotNull(principal);
|
||||
Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateToken_InvalidToken_ReturnsNull()
|
||||
{
|
||||
var service = CreateService();
|
||||
var result = service.ValidateToken("invalid.token.here");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateToken_WrongKey_ReturnsNull()
|
||||
{
|
||||
var service1 = CreateService();
|
||||
var token = service1.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
|
||||
var service2 = CreateService(new SecurityOptions
|
||||
{
|
||||
JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation",
|
||||
JwtExpiryMinutes = 15,
|
||||
IdleTimeoutMinutes = 30,
|
||||
JwtRefreshThresholdMinutes = 5
|
||||
});
|
||||
var result = service2.ValidateToken(token);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateToken_UsesHmacSha256()
|
||||
{
|
||||
var service = CreateService();
|
||||
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
|
||||
// Decode header to verify algorithm
|
||||
var parts = token.Split('.');
|
||||
var headerJson = System.Text.Encoding.UTF8.GetString(
|
||||
Convert.FromBase64String(parts[0].PadRight((parts[0].Length + 3) & ~3, '=')));
|
||||
Assert.Contains("HS256", headerJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRefresh_TokenNearExpiry_ReturnsTrue()
|
||||
{
|
||||
var options = CreateOptions();
|
||||
options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
|
||||
var service = CreateService(options);
|
||||
|
||||
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
var principal = service.ValidateToken(token);
|
||||
|
||||
Assert.True(service.ShouldRefresh(principal!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
|
||||
{
|
||||
var service = CreateService(); // 15 min expiry, 5 min threshold
|
||||
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
var principal = service.ValidateToken(token);
|
||||
|
||||
Assert.False(service.ShouldRefresh(principal!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
|
||||
{
|
||||
var service = CreateService();
|
||||
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
var principal = service.ValidateToken(token);
|
||||
|
||||
Assert.False(service.IsIdleTimedOut(principal!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue()
|
||||
{
|
||||
var service = CreateService();
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.DisplayNameClaimType, "User")
|
||||
}));
|
||||
|
||||
Assert.True(service.IsIdleTimedOut(principal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
|
||||
{
|
||||
var service = CreateService();
|
||||
var originalToken = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
||||
var principal = service.ValidateToken(originalToken);
|
||||
|
||||
var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" });
|
||||
Assert.NotNull(newToken);
|
||||
|
||||
var newPrincipal = service.ValidateToken(newToken!);
|
||||
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
||||
Assert.Contains("Design", roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RefreshToken_MissingClaims_ReturnsNull()
|
||||
{
|
||||
var service = CreateService();
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
var result = service.RefreshToken(principal, new[] { "Admin" }, null);
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WP-8: RoleMapper Tests
|
||||
|
||||
public class RoleMapperTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
private readonly SecurityRepository _securityRepo;
|
||||
private readonly RoleMapper _roleMapper;
|
||||
|
||||
public RoleMapperTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.UseSqlite("DataSource=:memory:")
|
||||
.Options;
|
||||
|
||||
_context = new ScadaLinkDbContext(options);
|
||||
_context.Database.OpenConnection();
|
||||
_context.Database.EnsureCreated();
|
||||
_securityRepo = new SecurityRepository(_context);
|
||||
_roleMapper = new RoleMapper(_securityRepo);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_MultiRoleExtraction()
|
||||
{
|
||||
// Add mappings (note: seed data adds SCADA-Admins -> Admin)
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design"));
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment"));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" });
|
||||
|
||||
Assert.Contains("Admin", result.Roles);
|
||||
Assert.Contains("Design", result.Roles);
|
||||
Assert.DoesNotContain("Deployment", result.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_SiteScopedDeployment()
|
||||
{
|
||||
var site1 = new Site("Site1", "S-001");
|
||||
var site2 = new Site("Site2", "S-002");
|
||||
_context.Sites.AddRange(site1, site2);
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment"));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers");
|
||||
_context.SiteScopeRules.AddRange(
|
||||
new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id },
|
||||
new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site2.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" });
|
||||
|
||||
Assert.Contains("Deployment", result.Roles);
|
||||
Assert.False(result.IsSystemWideDeployment);
|
||||
Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds);
|
||||
Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
|
||||
{
|
||||
_context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment"));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" });
|
||||
|
||||
Assert.Contains("Deployment", result.Roles);
|
||||
Assert.True(result.IsSystemWideDeployment);
|
||||
Assert.Empty(result.PermittedSiteIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored()
|
||||
{
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "NonExistentGroup", "AnotherRandom" });
|
||||
|
||||
Assert.Empty(result.Roles);
|
||||
Assert.Empty(result.PermittedSiteIds);
|
||||
Assert.False(result.IsSystemWideDeployment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles()
|
||||
{
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(Array.Empty<string>());
|
||||
|
||||
Assert.Empty(result.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch()
|
||||
{
|
||||
// "SCADA-Admins" is seeded
|
||||
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" });
|
||||
|
||||
Assert.Contains("Admin", result.Roles);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WP-9: Authorization Policy Tests
|
||||
|
||||
public class AuthorizationPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AdminPolicy_AdminRole_Succeeds()
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { "Admin" });
|
||||
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminPolicy_DesignRole_Fails()
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { "Design" });
|
||||
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DesignPolicy_DesignRole_Succeeds()
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { "Design" });
|
||||
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeploymentPolicy_DeploymentRole_Succeeds()
|
||||
{
|
||||
var principal = CreatePrincipal(new[] { "Deployment" });
|
||||
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRoles_DeniedAll()
|
||||
{
|
||||
var principal = CreatePrincipal(Array.Empty<string>());
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
|
||||
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteScope_SystemWideDeployer_Succeeds()
|
||||
{
|
||||
var handler = new SiteScopeAuthorizationHandler();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtTokenService.RoleClaimType, "Deployment")
|
||||
// No SiteId claims = system-wide
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var requirement = new SiteScopeRequirement("42");
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteScope_PermittedSite_Succeeds()
|
||||
{
|
||||
var handler = new SiteScopeAuthorizationHandler();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new(JwtTokenService.SiteIdClaimType, "1"),
|
||||
new(JwtTokenService.SiteIdClaimType, "2")
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var requirement = new SiteScopeRequirement("1");
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.True(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteScope_UnpermittedSite_Fails()
|
||||
{
|
||||
var handler = new SiteScopeAuthorizationHandler();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtTokenService.RoleClaimType, "Deployment"),
|
||||
new(JwtTokenService.SiteIdClaimType, "1")
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var requirement = new SiteScopeRequirement("99");
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiteScope_NoDeploymentRole_Fails()
|
||||
{
|
||||
var handler = new SiteScopeAuthorizationHandler();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtTokenService.RoleClaimType, "Admin")
|
||||
};
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
|
||||
var requirement = new SiteScopeRequirement("1");
|
||||
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
||||
|
||||
await handler.HandleAsync(context);
|
||||
|
||||
Assert.False(context.HasSucceeded);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
foreach (var role in roles)
|
||||
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
||||
if (siteIds != null)
|
||||
foreach (var siteId in siteIds)
|
||||
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
private static async Task<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScadaLinkAuthorization();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var authService = provider.GetRequiredService<IAuthorizationService>();
|
||||
|
||||
var result = await authService.AuthorizeAsync(principal, null, policyName);
|
||||
return result.Succeeded;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
Reference in New Issue
Block a user