Fix auth, Bootstrap, Blazor nav, LDAP, and deployment pipeline for working Central UI
Bootstrap served locally with absolute paths and <base href="/">. LDAP auth uses search-then-bind with service account for GLAuth compatibility. CookieAuthenticationStateProvider reads HttpContext.User instead of parsing JWT. Login/logout forms opt out of Blazor enhanced nav (data-enhance="false"). Nav links use absolute paths; seed data includes Design/Deployment group mappings. DataConnections page loads all connections (not just site-assigned). Site appsettings configured for Test Plant A; Site registers with Central on startup. DeploymentService resolves string site identifier for Akka routing. Instances page gains Create Instance form.
This commit is contained in:
@@ -2,55 +2,28 @@ using System.Security.Claims;
|
|||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Server;
|
using Microsoft.AspNetCore.Components.Server;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using ScadaLink.Security;
|
|
||||||
|
|
||||||
namespace ScadaLink.CentralUI.Auth;
|
namespace ScadaLink.CentralUI.Auth;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the JWT from an HTTP-only cookie and creates a ClaimsPrincipal for Blazor Server.
|
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
|
||||||
/// This bridges cookie-based auth (set by the login endpoint) with Blazor's auth state.
|
/// The cookie middleware has already validated and decrypted the cookie by the time
|
||||||
|
/// the Blazor circuit is established, so we just read HttpContext.User.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
||||||
{
|
{
|
||||||
public const string AuthCookieName = "ScadaLink.Auth";
|
|
||||||
|
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
private readonly JwtTokenService _jwtTokenService;
|
|
||||||
|
|
||||||
public CookieAuthenticationStateProvider(
|
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
|
||||||
IHttpContextAccessor httpContextAccessor,
|
|
||||||
JwtTokenService jwtTokenService)
|
|
||||||
{
|
{
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
_jwtTokenService = jwtTokenService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
{
|
{
|
||||||
var httpContext = _httpContextAccessor.HttpContext;
|
var user = _httpContextAccessor.HttpContext?.User
|
||||||
if (httpContext == null)
|
?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||||
{
|
|
||||||
return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
|
|
||||||
}
|
|
||||||
|
|
||||||
var token = httpContext.Request.Cookies[AuthCookieName];
|
return Task.FromResult(new AuthenticationState(user));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,19 +15,19 @@
|
|||||||
<Authorized Context="adminContext">
|
<Authorized Context="adminContext">
|
||||||
<li class="nav-section-header">Admin</li>
|
<li class="nav-section-header">Admin</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="admin/ldap-mappings">LDAP Mappings</NavLink>
|
<NavLink class="nav-link" href="/admin/ldap-mappings">LDAP Mappings</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="admin/sites">Sites</NavLink>
|
<NavLink class="nav-link" href="/admin/sites">Sites</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="admin/data-connections">Data Connections</NavLink>
|
<NavLink class="nav-link" href="/admin/data-connections">Data Connections</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="admin/areas">Areas</NavLink>
|
<NavLink class="nav-link" href="/admin/areas">Areas</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="admin/api-keys">API Keys</NavLink>
|
<NavLink class="nav-link" href="/admin/api-keys">API Keys</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@@ -37,13 +37,13 @@
|
|||||||
<Authorized Context="designContext">
|
<Authorized Context="designContext">
|
||||||
<li class="nav-section-header">Design</li>
|
<li class="nav-section-header">Design</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="design/templates">Templates</NavLink>
|
<NavLink class="nav-link" href="/design/templates">Templates</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="design/shared-scripts">Shared Scripts</NavLink>
|
<NavLink class="nav-link" href="/design/shared-scripts">Shared Scripts</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="design/external-systems">External Systems</NavLink>
|
<NavLink class="nav-link" href="/design/external-systems">External Systems</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@@ -53,13 +53,13 @@
|
|||||||
<Authorized Context="deploymentContext">
|
<Authorized Context="deploymentContext">
|
||||||
<li class="nav-section-header">Deployment</li>
|
<li class="nav-section-header">Deployment</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="deployment/instances">Instances</NavLink>
|
<NavLink class="nav-link" href="/deployment/instances">Instances</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="deployment/deployments">Deployments</NavLink>
|
<NavLink class="nav-link" href="/deployment/deployments">Deployments</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="deployment/debug-view">Debug View</NavLink>
|
<NavLink class="nav-link" href="/deployment/debug-view">Debug View</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@@ -67,20 +67,20 @@
|
|||||||
@* Monitoring — visible to all authenticated users *@
|
@* Monitoring — visible to all authenticated users *@
|
||||||
<li class="nav-section-header">Monitoring</li>
|
<li class="nav-section-header">Monitoring</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="monitoring/health">Health Dashboard</NavLink>
|
<NavLink class="nav-link" href="/monitoring/health">Health Dashboard</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="monitoring/event-logs">Event Logs</NavLink>
|
<NavLink class="nav-link" href="/monitoring/event-logs">Event Logs</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="monitoring/parked-messages">Parked Messages</NavLink>
|
<NavLink class="nav-link" href="/monitoring/parked-messages">Parked Messages</NavLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@* Audit Log — Admin only *@
|
@* Audit Log — Admin only *@
|
||||||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||||||
<Authorized Context="auditContext">
|
<Authorized Context="auditContext">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<NavLink class="nav-link" href="monitoring/audit-log">Audit Log</NavLink>
|
<NavLink class="nav-link" href="/monitoring/audit-log">Audit Log</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<Authorized>
|
<Authorized>
|
||||||
<div class="border-top border-secondary p-2">
|
<div class="border-top border-secondary p-2">
|
||||||
<span class="d-block text-light small px-2">@context.User.FindFirst("DisplayName")?.Value</span>
|
<span class="d-block text-light small px-2">@context.User.FindFirst("DisplayName")?.Value</span>
|
||||||
<form method="post" action="/auth/logout">
|
<form method="post" action="/auth/logout" data-enhance="false">
|
||||||
<button type="submit" class="btn btn-link btn-sm text-muted text-decoration-none px-2">Sign Out</button>
|
<button type="submit" class="btn btn-link btn-sm text-muted text-decoration-none px-2">Sign Out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -199,17 +199,15 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||||
|
_connections = (await SiteRepository.GetAllDataConnectionsAsync()).ToList();
|
||||||
|
|
||||||
// Load all connections by iterating all sites and collecting unique connections
|
// Load site assignments for each connection
|
||||||
var allConnections = new Dictionary<int, DataConnection>();
|
|
||||||
_connectionSites.Clear();
|
_connectionSites.Clear();
|
||||||
|
|
||||||
foreach (var site in _sites)
|
foreach (var site in _sites)
|
||||||
{
|
{
|
||||||
var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
|
var siteConns = await SiteRepository.GetDataConnectionsBySiteIdAsync(site.Id);
|
||||||
foreach (var conn in siteConns)
|
foreach (var conn in siteConns)
|
||||||
{
|
{
|
||||||
allConnections[conn.Id] = conn;
|
|
||||||
if (!_connectionSites.ContainsKey(conn.Id))
|
if (!_connectionSites.ContainsKey(conn.Id))
|
||||||
_connectionSites[conn.Id] = new List<SiteDataConnectionAssignment>();
|
_connectionSites[conn.Id] = new List<SiteDataConnectionAssignment>();
|
||||||
|
|
||||||
@@ -218,8 +216,6 @@
|
|||||||
_connectionSites[conn.Id].Add(assignment);
|
_connectionSites[conn.Id].Add(assignment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_connections = allConnections.Values.OrderBy(c => c.Name).ToList();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,15 +7,18 @@
|
|||||||
@using ScadaLink.Commons.Interfaces.Repositories
|
@using ScadaLink.Commons.Interfaces.Repositories
|
||||||
@using ScadaLink.Commons.Types.Enums
|
@using ScadaLink.Commons.Types.Enums
|
||||||
@using ScadaLink.DeploymentManager
|
@using ScadaLink.DeploymentManager
|
||||||
|
@using ScadaLink.TemplateEngine.Services
|
||||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||||
@inject ISiteRepository SiteRepository
|
@inject ISiteRepository SiteRepository
|
||||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||||
@inject DeploymentService DeploymentService
|
@inject DeploymentService DeploymentService
|
||||||
|
@inject InstanceService InstanceService
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h4 class="mb-0">Instances</h4>
|
<h4 class="mb-0">Instances</h4>
|
||||||
|
<button class="btn btn-primary btn-sm" @onclick="ShowCreateForm">Create Instance</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToastNotification @ref="_toast" />
|
<ToastNotification @ref="_toast" />
|
||||||
@@ -31,6 +34,49 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@if (_showCreateForm)
|
||||||
|
{
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Create Instance</h6>
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Instance Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" @bind="_createName" placeholder="e.g. Motor-1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Template</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="_createTemplateId">
|
||||||
|
<option value="0">Select template...</option>
|
||||||
|
@foreach (var t in _templates)
|
||||||
|
{
|
||||||
|
<option value="@t.Id">@t.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Site</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="_createSiteId">
|
||||||
|
<option value="0">Select site...</option>
|
||||||
|
@foreach (var s in _sites)
|
||||||
|
{
|
||||||
|
<option value="@s.Id">@s.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCreateForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (_createError != null)
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-1">@_createError</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Filters *@
|
@* Filters *@
|
||||||
<div class="row mb-3 g-2">
|
<div class="row mb-3 g-2">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
@@ -365,4 +411,48 @@
|
|||||||
}
|
}
|
||||||
_actionInProgress = false;
|
_actionInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create instance form
|
||||||
|
private bool _showCreateForm;
|
||||||
|
private string _createName = string.Empty;
|
||||||
|
private int _createTemplateId;
|
||||||
|
private int _createSiteId;
|
||||||
|
private string? _createError;
|
||||||
|
|
||||||
|
private void ShowCreateForm()
|
||||||
|
{
|
||||||
|
_createName = string.Empty;
|
||||||
|
_createTemplateId = 0;
|
||||||
|
_createSiteId = 0;
|
||||||
|
_createError = null;
|
||||||
|
_showCreateForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateInstance()
|
||||||
|
{
|
||||||
|
_createError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(_createName)) { _createError = "Instance name is required."; return; }
|
||||||
|
if (_createTemplateId == 0) { _createError = "Select a template."; return; }
|
||||||
|
if (_createSiteId == 0) { _createError = "Select a site."; return; }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await InstanceService.CreateInstanceAsync(
|
||||||
|
_createName.Trim(), _createTemplateId, _createSiteId, null, "system");
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
_showCreateForm = false;
|
||||||
|
_toast.ShowSuccess($"Instance '{_createName}' created.");
|
||||||
|
await LoadDataAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_createError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_createError = $"Create failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
|
<div class="alert alert-danger py-2" role="alert">@ErrorMessage</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form method="post" action="/auth/login">
|
<form method="post" action="/auth/login" data-enhance="false">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Username</label>
|
<label for="username" class="form-label">Username</label>
|
||||||
<input type="text" class="form-control" id="username" name="username"
|
<input type="text" class="form-control" id="username" name="username"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public interface ISiteRepository
|
|||||||
|
|
||||||
// Data Connections
|
// Data Connections
|
||||||
Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<DataConnection?> GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default);
|
||||||
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default);
|
||||||
Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
|
Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
|
||||||
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
|
Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public class CommunicationOptions
|
|||||||
/// <summary>Timeout for health report acknowledgement (fire-and-forget, but bounded).</summary>
|
/// <summary>Timeout for health report acknowledgement (fire-and-forget, but bounded).</summary>
|
||||||
public TimeSpan HealthReportTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
public TimeSpan HealthReportTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remote actor path for the central communication actor. Used by site nodes to
|
||||||
|
/// register with central on startup (e.g. "akka.tcp://scadalink@central:8081/user/central-communication").
|
||||||
|
/// </summary>
|
||||||
|
public string? CentralActorPath { get; set; }
|
||||||
|
|
||||||
/// <summary>Akka.Remote transport heartbeat interval.</summary>
|
/// <summary>Akka.Remote transport heartbeat interval.</summary>
|
||||||
public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
|
public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration<LdapGroupM
|
|||||||
|
|
||||||
builder.HasIndex(m => m.LdapGroupName).IsUnique();
|
builder.HasIndex(m => m.LdapGroupName).IsUnique();
|
||||||
|
|
||||||
// Seed default admin mapping
|
// Seed default group mappings matching GLAuth test users
|
||||||
builder.HasData(new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 });
|
builder.HasData(
|
||||||
|
new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 },
|
||||||
|
new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 },
|
||||||
|
new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 },
|
||||||
|
new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ public class SiteRepository : ISiteRepository
|
|||||||
return await _dbContext.DataConnections.FindAsync([id], cancellationToken);
|
return await _dbContext.DataConnections.FindAsync([id], cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DataConnection>> GetAllDataConnectionsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _dbContext.DataConnections.OrderBy(c => c.Name).ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<DataConnection>> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var connectionIds = await _dbContext.SiteDataConnectionAssignments
|
var connectionIds = await _dbContext.SiteDataConnectionAssignments
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ namespace ScadaLink.DeploymentManager;
|
|||||||
public class DeploymentService
|
public class DeploymentService
|
||||||
{
|
{
|
||||||
private readonly IDeploymentManagerRepository _repository;
|
private readonly IDeploymentManagerRepository _repository;
|
||||||
|
private readonly ISiteRepository _siteRepository;
|
||||||
private readonly IFlatteningPipeline _flatteningPipeline;
|
private readonly IFlatteningPipeline _flatteningPipeline;
|
||||||
private readonly CommunicationService _communicationService;
|
private readonly CommunicationService _communicationService;
|
||||||
private readonly OperationLockManager _lockManager;
|
private readonly OperationLockManager _lockManager;
|
||||||
@@ -44,6 +45,7 @@ public class DeploymentService
|
|||||||
|
|
||||||
public DeploymentService(
|
public DeploymentService(
|
||||||
IDeploymentManagerRepository repository,
|
IDeploymentManagerRepository repository,
|
||||||
|
ISiteRepository siteRepository,
|
||||||
IFlatteningPipeline flatteningPipeline,
|
IFlatteningPipeline flatteningPipeline,
|
||||||
CommunicationService communicationService,
|
CommunicationService communicationService,
|
||||||
OperationLockManager lockManager,
|
OperationLockManager lockManager,
|
||||||
@@ -52,6 +54,7 @@ public class DeploymentService
|
|||||||
ILogger<DeploymentService> logger)
|
ILogger<DeploymentService> logger)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
|
_siteRepository = siteRepository;
|
||||||
_flatteningPipeline = flatteningPipeline;
|
_flatteningPipeline = flatteningPipeline;
|
||||||
_communicationService = communicationService;
|
_communicationService = communicationService;
|
||||||
_lockManager = lockManager;
|
_lockManager = lockManager;
|
||||||
@@ -60,6 +63,16 @@ public class DeploymentService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the site's string identifier from the numeric DB ID.
|
||||||
|
/// The communication layer routes by string identifier (e.g. "site-a"), not DB ID.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ResolveSiteIdentifierAsync(int siteId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||||
|
return site?.SiteIdentifier ?? siteId.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-1: Deploy an instance to its site.
|
/// WP-1: Deploy an instance to its site.
|
||||||
/// WP-2: Generates unique deployment ID, computes revision hash.
|
/// WP-2: Generates unique deployment ID, computes revision hash.
|
||||||
@@ -128,7 +141,7 @@ public class DeploymentService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// WP-1: Send to site via CommunicationService
|
// WP-1: Send to site via CommunicationService
|
||||||
var siteId = instance.SiteId.ToString();
|
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||||
var command = new DeployInstanceCommand(
|
var command = new DeployInstanceCommand(
|
||||||
deploymentId, instance.UniqueName, revisionHash, configJson, user, DateTimeOffset.UtcNow);
|
deploymentId, instance.UniqueName, revisionHash, configJson, user, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
@@ -206,7 +219,7 @@ public class DeploymentService
|
|||||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||||
|
|
||||||
var commandId = Guid.NewGuid().ToString("N");
|
var commandId = Guid.NewGuid().ToString("N");
|
||||||
var siteId = instance.SiteId.ToString();
|
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||||
var command = new DisableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
var command = new DisableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
var response = await _communicationService.DisableInstanceAsync(siteId, command, cancellationToken);
|
var response = await _communicationService.DisableInstanceAsync(siteId, command, cancellationToken);
|
||||||
@@ -247,7 +260,7 @@ public class DeploymentService
|
|||||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||||
|
|
||||||
var commandId = Guid.NewGuid().ToString("N");
|
var commandId = Guid.NewGuid().ToString("N");
|
||||||
var siteId = instance.SiteId.ToString();
|
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||||
var command = new EnableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
var command = new EnableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
var response = await _communicationService.EnableInstanceAsync(siteId, command, cancellationToken);
|
var response = await _communicationService.EnableInstanceAsync(siteId, command, cancellationToken);
|
||||||
@@ -289,7 +302,7 @@ public class DeploymentService
|
|||||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||||
|
|
||||||
var commandId = Guid.NewGuid().ToString("N");
|
var commandId = Guid.NewGuid().ToString("N");
|
||||||
var siteId = instance.SiteId.ToString();
|
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||||
var command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
var command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
var response = await _communicationService.DeleteInstanceAsync(siteId, command, cancellationToken);
|
var response = await _communicationService.DeleteInstanceAsync(siteId, command, cancellationToken);
|
||||||
|
|||||||
@@ -232,5 +232,21 @@ akka {{
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.",
|
"Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.",
|
||||||
siteRole);
|
siteRole);
|
||||||
|
|
||||||
|
// Register with Central if configured — tells Central where to send deployment commands
|
||||||
|
if (!string.IsNullOrWhiteSpace(_communicationOptions.CentralActorPath))
|
||||||
|
{
|
||||||
|
var siteCommActor = _actorSystem.ActorSelection("/user/site-communication");
|
||||||
|
siteCommActor.Tell(new RegisterCentralPath(_communicationOptions.CentralActorPath));
|
||||||
|
|
||||||
|
// Also register this site with Central so it knows our address
|
||||||
|
var centralSelection = _actorSystem.ActorSelection(_communicationOptions.CentralActorPath);
|
||||||
|
var localSiteCommPath = $"akka.tcp://scadalink@{_nodeOptions.NodeHostname}:{_nodeOptions.RemotingPort}/user/site-communication";
|
||||||
|
centralSelection.Tell(new RegisterSite(_nodeOptions.SiteId!, localSiteCommPath));
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Registered with Central at {CentralPath} as site {SiteId}",
|
||||||
|
_communicationOptions.CentralActorPath, _nodeOptions.SiteId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
<title>ScadaLink</title>
|
<title>ScadaLink</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
rel="stylesheet"
|
|
||||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YcnS/1p0TQGL3BNgcree90f9QM0jB1zDTkM6"
|
|
||||||
crossorigin="anonymous" />
|
|
||||||
<style>
|
<style>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
@@ -82,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="_framework/blazor.web.js"></script>
|
<script src="/_framework/blazor.web.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Reconnection overlay for failover behavior
|
// Reconnection overlay for failover behavior
|
||||||
if (Blazor) {
|
if (Blazor) {
|
||||||
@@ -91,8 +89,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ try
|
|||||||
services.AddExternalSystemGateway();
|
services.AddExternalSystemGateway();
|
||||||
services.AddNotificationService();
|
services.AddNotificationService();
|
||||||
|
|
||||||
|
// Configuration database (read-only access for external system definitions, notification lists)
|
||||||
|
var configDbConnectionString = context.Configuration["ScadaLink:Database:ConfigurationDb"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(configDbConnectionString))
|
||||||
|
{
|
||||||
|
services.AddConfigurationDatabase(configDbConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
// Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path
|
// Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path
|
||||||
var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db";
|
var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db";
|
||||||
services.AddSiteRuntime($"Data Source={siteDbPath}");
|
services.AddSiteRuntime($"Data Source={siteDbPath}");
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
"LdapUseTls": false,
|
"LdapUseTls": false,
|
||||||
"AllowInsecureLdap": true,
|
"AllowInsecureLdap": true,
|
||||||
"LdapSearchBase": "dc=scadalink,dc=local",
|
"LdapSearchBase": "dc=scadalink,dc=local",
|
||||||
|
"LdapServiceAccountDn": "cn=admin,dc=scadalink,dc=local",
|
||||||
|
"LdapServiceAccountPassword": "password",
|
||||||
"JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
"JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long",
|
||||||
"JwtExpiryMinutes": 15,
|
"JwtExpiryMinutes": 15,
|
||||||
"IdleTimeoutMinutes": 30
|
"IdleTimeoutMinutes": 30
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
"ScadaLink": {
|
"ScadaLink": {
|
||||||
"Node": {
|
"Node": {
|
||||||
"Role": "Site",
|
"Role": "Site",
|
||||||
"NodeHostname": "site-a-node1",
|
"NodeHostname": "localhost",
|
||||||
"SiteId": "SiteA",
|
"SiteId": "site-a",
|
||||||
"RemotingPort": 8082
|
"RemotingPort": 8082
|
||||||
},
|
},
|
||||||
"Cluster": {
|
"Cluster": {
|
||||||
"SeedNodes": [
|
"SeedNodes": [
|
||||||
"akka.tcp://scadalink@site-a-node1:8082",
|
"akka.tcp://scadalink@localhost:8082",
|
||||||
"akka.tcp://scadalink@site-a-node2:8082"
|
"akka.tcp://scadalink@localhost:8083"
|
||||||
],
|
],
|
||||||
"SplitBrainResolverStrategy": "keep-oldest",
|
"SplitBrainResolverStrategy": "keep-oldest",
|
||||||
"StableAfter": "00:00:15",
|
"StableAfter": "00:00:15",
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
"MinNrOfMembers": 1
|
"MinNrOfMembers": 1
|
||||||
},
|
},
|
||||||
"Database": {
|
"Database": {
|
||||||
"SiteDbPath": "./data/scadalink.db"
|
"SiteDbPath": "./data/scadalink.db",
|
||||||
|
"ConfigurationDb": "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true"
|
||||||
},
|
},
|
||||||
"DataConnection": {
|
"DataConnection": {
|
||||||
"ReconnectInterval": "00:00:05",
|
"ReconnectInterval": "00:00:05",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"ReplicationEnabled": true
|
"ReplicationEnabled": true
|
||||||
},
|
},
|
||||||
"Communication": {
|
"Communication": {
|
||||||
|
"CentralActorPath": "akka.tcp://scadalink@localhost:8081/user/central-communication",
|
||||||
"DeploymentTimeout": "00:02:00",
|
"DeploymentTimeout": "00:02:00",
|
||||||
"LifecycleTimeout": "00:00:30",
|
"LifecycleTimeout": "00:00:30",
|
||||||
"QueryTimeout": "00:00:30",
|
"QueryTimeout": "00:00:30",
|
||||||
|
|||||||
6
src/ScadaLink.Host/wwwroot/lib/bootstrap/css/bootstrap.min.css
vendored
Normal file
6
src/ScadaLink.Host/wwwroot/lib/bootstrap/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
7
src/ScadaLink.Host/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
7
src/ScadaLink.Host/wwwroot/lib/bootstrap/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -46,10 +46,17 @@ public class LdapAuthService
|
|||||||
await Task.Run(() => connection.StartTls(), ct);
|
await Task.Run(() => connection.StartTls(), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct bind with user credentials
|
// Resolve the user's actual DN, then bind with their credentials
|
||||||
var bindDn = BuildBindDn(username);
|
var bindDn = await ResolveUserDnAsync(connection, username, ct);
|
||||||
await Task.Run(() => connection.Bind(bindDn, password), ct);
|
await Task.Run(() => connection.Bind(bindDn, password), ct);
|
||||||
|
|
||||||
|
// Re-bind as service account for attribute/group lookup (user may lack search rights)
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
|
||||||
|
}
|
||||||
|
|
||||||
// Query for user attributes and group memberships
|
// Query for user attributes and group memberships
|
||||||
var displayName = username;
|
var displayName = username;
|
||||||
var groups = new List<string>();
|
var groups = new List<string>();
|
||||||
@@ -79,7 +86,7 @@ public class LdapAuthService
|
|||||||
{
|
{
|
||||||
foreach (var groupDn in groupAttr.StringValueArray)
|
foreach (var groupDn in groupAttr.StringValueArray)
|
||||||
{
|
{
|
||||||
groups.Add(ExtractCn(groupDn));
|
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,13 +119,41 @@ public class LdapAuthService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildBindDn(string username)
|
/// <summary>
|
||||||
|
/// Resolves the user's full DN. When a service account is configured, performs a
|
||||||
|
/// search-then-bind lookup. Otherwise falls back to constructing the DN directly.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// If username already looks like a DN, use it as-is
|
// If username already looks like a DN, use it as-is
|
||||||
if (username.Contains('='))
|
if (username.Contains('='))
|
||||||
return username;
|
return username;
|
||||||
|
|
||||||
// Build DN from username and search base
|
// If a service account is configured, search for the user's actual DN
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
|
||||||
|
|
||||||
|
var searchFilter = $"(uid={EscapeLdapFilter(username)})";
|
||||||
|
var searchResults = await Task.Run(() =>
|
||||||
|
connection.Search(
|
||||||
|
_options.LdapSearchBase,
|
||||||
|
LdapConnection.ScopeSub,
|
||||||
|
searchFilter,
|
||||||
|
new[] { "dn" },
|
||||||
|
false), ct);
|
||||||
|
|
||||||
|
if (searchResults.HasMore())
|
||||||
|
{
|
||||||
|
var entry = searchResults.Next();
|
||||||
|
return entry.Dn;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new LdapException("User not found", LdapException.NoSuchObject, $"No entry found for uid={username}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: construct DN directly
|
||||||
return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
|
return string.IsNullOrWhiteSpace(_options.LdapSearchBase)
|
||||||
? $"cn={username}"
|
? $"cn={username}"
|
||||||
: $"cn={username},{_options.LdapSearchBase}";
|
: $"cn={username},{_options.LdapSearchBase}";
|
||||||
@@ -134,15 +169,16 @@ public class LdapAuthService
|
|||||||
.Replace("\0", "\\00");
|
.Replace("\0", "\\00");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExtractCn(string dn)
|
private static string ExtractFirstRdnValue(string dn)
|
||||||
{
|
{
|
||||||
// Extract CN from a DN like "cn=GroupName,dc=example,dc=com"
|
// Extract the value of the first RDN from a DN.
|
||||||
if (dn.StartsWith("cn=", StringComparison.OrdinalIgnoreCase) ||
|
// Handles cn=, ou=, or any attribute: "ou=SCADA-Admins,ou=groups,dc=..." → "SCADA-Admins"
|
||||||
dn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
var equalsIndex = dn.IndexOf('=');
|
||||||
{
|
if (equalsIndex < 0)
|
||||||
var commaIndex = dn.IndexOf(',');
|
return dn;
|
||||||
return commaIndex > 3 ? dn[3..commaIndex] : dn[3..];
|
|
||||||
}
|
var valueStart = equalsIndex + 1;
|
||||||
return dn;
|
var commaIndex = dn.IndexOf(',', valueStart);
|
||||||
|
return commaIndex > valueStart ? dn[valueStart..commaIndex] : dn[valueStart..];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ public class SecurityOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string LdapSearchBase { get; set; } = string.Empty;
|
public string LdapSearchBase { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
|
||||||
|
/// Required for search-then-bind authentication. If empty, direct bind with
|
||||||
|
/// cn={username},{LdapSearchBase} is attempted instead.
|
||||||
|
/// </summary>
|
||||||
|
public string LdapServiceAccountDn { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service account password for LDAP user searches.
|
||||||
|
/// </summary>
|
||||||
|
public string LdapServiceAccountPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// LDAP attribute that contains the user's display name.
|
/// LDAP attribute that contains the user's display name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ public class DeploymentServiceTests
|
|||||||
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
OperationLockTimeout = TimeSpan.FromSeconds(5)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var siteRepo = Substitute.For<ISiteRepository>();
|
||||||
_service = new DeploymentService(
|
_service = new DeploymentService(
|
||||||
_repo, _pipeline, _comms, _lockManager, _audit, options,
|
_repo, siteRepo, _pipeline, _comms, _lockManager, _audit, options,
|
||||||
NullLogger<DeploymentService>.Instance);
|
NullLogger<DeploymentService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ public class AuthFlowTests : IClassFixture<ScadaLinkWebApplicationFactory>
|
|||||||
// Verify auth cookie was set
|
// Verify auth cookie was set
|
||||||
var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault();
|
var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault();
|
||||||
Assert.NotNull(setCookieHeader);
|
Assert.NotNull(setCookieHeader);
|
||||||
Assert.Contains(CookieAuthenticationStateProvider.AuthCookieName, setCookieHeader);
|
Assert.Contains("ScadaLink.Auth", setCookieHeader);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<bool> IsLdapAvailableAsync()
|
private static async Task<bool> IsLdapAvailableAsync()
|
||||||
|
|||||||
Reference in New Issue
Block a user