feat(auth): ScadaBridge CentralUI pages onto IInboundApiKeyAdmin seam (re-arch C3; string keyId, method-scopes replace ApprovedApiKeyIds, token-once display, approved-keys<->scopes inversion)
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
@page "/admin/api-keys/create"
|
||||
@page "/admin/api-keys/{Id:int}/edit"
|
||||
@page "/admin/api-keys/{KeyId}/edit"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@@ -46,15 +48,16 @@
|
||||
{
|
||||
<LoadingSpinner IsLoading="true" />
|
||||
}
|
||||
else if (_saved && _newlyCreatedKeyValue != null)
|
||||
else if (_saved && _newlyCreatedToken != null)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
<strong>New API Key Created</strong>
|
||||
<div class="d-flex align-items-center mt-1">
|
||||
<code class="me-2">@_newlyCreatedKeyValue</code>
|
||||
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
|
||||
<div class="d-flex align-items-center mt-2">
|
||||
<code class="me-2" data-test="created-token">@_newlyCreatedToken</code>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">Save this key now. It will not be shown again in full.</small>
|
||||
<small class="text-muted d-block mt-1">Save this token now — it will not be shown again.</small>
|
||||
</div>
|
||||
<a href="/admin/api-keys" class="btn btn-primary btn-sm">Back to API Keys</a>
|
||||
}
|
||||
@@ -66,39 +69,37 @@
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
@* Name is fixed on edit — the seam has no rename. *@
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" disabled="@IsEditMode" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">API Method Access</label>
|
||||
@if (_allMethods.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API methods configured.
|
||||
<a href="/design/external-systems">Create one</a> to grant access.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var method in _allMethods.OrderBy(m => m.Name))
|
||||
{
|
||||
var checkboxId = $"method-access-{method.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedMethodNames.Contains(method.Name)"
|
||||
@onchange="e => ToggleMethod(method.Name, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">@method.Name</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers using this key can invoke any checked method. At least one is required.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (IsEditMode)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">API Method Access</label>
|
||||
@if (_allMethods.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
No API methods configured.
|
||||
<a href="/design/external-systems">Create one</a> to grant access.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var method in _allMethods.OrderBy(m => m.Name))
|
||||
{
|
||||
var checkboxId = $"method-access-{method.Id}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedMethodIds.Contains(method.Id)"
|
||||
@onchange="e => ToggleMethod(method.Id, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">@method.Name</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Callers using this key can invoke any checked method.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
@@ -111,21 +112,26 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int? Id { get; set; }
|
||||
// Inbound-API key re-arch (C3): this form drives the IInboundApiKeyAdmin seam.
|
||||
// Keys are identified by an opaque string KeyId; method access is a set of method
|
||||
// NAMES (scopes) carried on the key, replacing the old ApiMethod.ApprovedApiKeyIds CSV.
|
||||
// The list of all methods still comes from IInboundApiRepository (methods stay in SQL).
|
||||
[Parameter] public string? KeyId { get; set; }
|
||||
|
||||
private bool IsEditMode => _editingKey != null;
|
||||
|
||||
private ApiKey? _editingKey;
|
||||
private InboundApiKeyInfo? _editingKey;
|
||||
private string _formName = string.Empty;
|
||||
private string? _formError;
|
||||
private string? _errorMessage;
|
||||
private string? _newlyCreatedKeyValue;
|
||||
private string? _newlyCreatedToken;
|
||||
private string? _newlyCreatedKeyId;
|
||||
private bool _loading = true;
|
||||
private bool _saved;
|
||||
|
||||
private List<ApiMethod> _allMethods = new();
|
||||
private HashSet<int> _initialMethodIds = new();
|
||||
private HashSet<int> _selectedMethodIds = new();
|
||||
// Selection set is method NAMES (scopes), not method ids.
|
||||
private HashSet<string> _selectedMethodNames = new(StringComparer.Ordinal);
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
@@ -133,22 +139,23 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Id.HasValue)
|
||||
// Methods always come from SQL Server (methods stay on the repository).
|
||||
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(KeyId))
|
||||
{
|
||||
_editingKey = await InboundApiRepository.GetApiKeyByIdAsync(Id.Value);
|
||||
// No single-key getter on the seam — locate this key in the full list.
|
||||
var all = await ApiKeyAdmin.ListAsync();
|
||||
_editingKey = all.FirstOrDefault(k => string.Equals(k.KeyId, KeyId, StringComparison.Ordinal));
|
||||
if (_editingKey == null)
|
||||
{
|
||||
_errorMessage = $"API key with ID {Id.Value} not found.";
|
||||
_errorMessage = $"API key '{KeyId}' not found.";
|
||||
}
|
||||
else
|
||||
{
|
||||
_formName = _editingKey.Name;
|
||||
_allMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList();
|
||||
_initialMethodIds = _allMethods
|
||||
.Where(m => ParseApprovedKeyIds(m.ApprovedApiKeyIds).Contains(_editingKey.Id))
|
||||
.Select(m => m.Id)
|
||||
.ToHashSet();
|
||||
_selectedMethodIds = new HashSet<int>(_initialMethodIds);
|
||||
var methods = await ApiKeyAdmin.GetMethodsForKeyAsync(KeyId);
|
||||
_selectedMethodNames = new HashSet<string>(methods, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,40 +169,29 @@
|
||||
private async Task SaveKey()
|
||||
{
|
||||
_formError = null;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
// The seam/server reject empty scope sets; validate in the UI for a clear message.
|
||||
if (_selectedMethodNames.Count == 0)
|
||||
{
|
||||
_formError = "Select at least one API method for this key.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_editingKey != null)
|
||||
{
|
||||
_editingKey.Name = _formName.Trim();
|
||||
await InboundApiRepository.UpdateApiKeyAsync(_editingKey);
|
||||
|
||||
var changedIds = _selectedMethodIds
|
||||
.Except(_initialMethodIds)
|
||||
.Concat(_initialMethodIds.Except(_selectedMethodIds))
|
||||
.ToHashSet();
|
||||
foreach (var method in _allMethods.Where(m => changedIds.Contains(m.Id)))
|
||||
{
|
||||
var ids = ParseApprovedKeyIds(method.ApprovedApiKeyIds);
|
||||
if (_selectedMethodIds.Contains(method.Id)) ids.Add(_editingKey.Id);
|
||||
else ids.Remove(_editingKey.Id);
|
||||
method.ApprovedApiKeyIds = ids.Count == 0
|
||||
? null
|
||||
: string.Join(",", ids.OrderBy(x => x));
|
||||
await InboundApiRepository.UpdateApiMethodAsync(method);
|
||||
}
|
||||
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
// Edit: name is fixed; only the method-scope set is mutable.
|
||||
await ApiKeyAdmin.SetMethodsAsync(_editingKey.KeyId, _selectedMethodNames.ToList());
|
||||
NavigationManager.NavigateTo("/admin/api-keys");
|
||||
}
|
||||
else
|
||||
{
|
||||
var keyValue = GenerateApiKey();
|
||||
var key = new ApiKey(_formName.Trim(), keyValue) { IsEnabled = true };
|
||||
await InboundApiRepository.AddApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_newlyCreatedKeyValue = keyValue;
|
||||
if (string.IsNullOrWhiteSpace(_formName)) { _formError = "Name is required."; return; }
|
||||
|
||||
var created = await ApiKeyAdmin.CreateAsync(_formName.Trim(), _selectedMethodNames.ToList());
|
||||
_newlyCreatedKeyId = created.KeyId;
|
||||
_newlyCreatedToken = created.Token; // shown once; never persisted client-side.
|
||||
_saved = true;
|
||||
}
|
||||
}
|
||||
@@ -207,28 +203,18 @@
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/admin/api-keys");
|
||||
|
||||
private void ToggleMethod(int methodId, bool isChecked)
|
||||
private void ToggleMethod(string methodName, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedMethodIds.Add(methodId);
|
||||
else _selectedMethodIds.Remove(methodId);
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
if (isChecked) _selectedMethodNames.Add(methodName);
|
||||
else _selectedMethodNames.Remove(methodName);
|
||||
}
|
||||
|
||||
private async Task CopyKeyToClipboard()
|
||||
{
|
||||
if (_newlyCreatedKeyValue == null) return;
|
||||
if (_newlyCreatedToken == null) return;
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedKeyValue);
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _newlyCreatedToken);
|
||||
_toast.ShowSuccess("Copied to clipboard.");
|
||||
}
|
||||
catch
|
||||
@@ -236,12 +222,4 @@
|
||||
_toast.ShowError("Copy failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Convert.ToBase64String(bytes).Replace("+", "").Replace("/", "").Replace("=", "")[..40];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
@page "/admin/api-keys"
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IDialogService Dialog
|
||||
|
||||
@@ -44,29 +43,29 @@
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Key ID</th>
|
||||
<th>Name</th>
|
||||
<th>Key Hash</th>
|
||||
<th>Methods</th>
|
||||
<th style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var key in FilteredKeys)
|
||||
{
|
||||
<tr @key="key.Id">
|
||||
<td>@key.Id</td>
|
||||
<tr @key="key.KeyId">
|
||||
<td><code>@TruncateKeyId(key.KeyId)</code></td>
|
||||
<td>
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
</td>
|
||||
<td><code>@MaskKeyValue(key.KeyHash)</code></td>
|
||||
<td>@key.Methods.Count</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.Id}/edit")'>Edit</button>
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
|
||||
data-bs-toggle="dropdown"
|
||||
@@ -75,7 +74,7 @@
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
@onclick="() => ToggleKey(key)">
|
||||
@(key.IsEnabled ? "Disable" : "Enable")
|
||||
@(key.Enabled ? "Disable" : "Enable")
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider" /></li>
|
||||
@@ -98,14 +97,17 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<ApiKey> _keys = new();
|
||||
// Inbound-API key re-arch (C3): this page reads keys from the IInboundApiKeyAdmin seam
|
||||
// (string KeyId, method-scopes) rather than the SQL Server ApiKey entity. The seam has no
|
||||
// retrievable hash, so the old masked Key-Hash column is gone; KeyId identifies each row.
|
||||
private List<InboundApiKeyInfo> _keys = new();
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private string _search = string.Empty;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private IEnumerable<ApiKey> FilteredKeys =>
|
||||
private IEnumerable<InboundApiKeyInfo> FilteredKeys =>
|
||||
string.IsNullOrWhiteSpace(_search)
|
||||
? _keys
|
||||
: _keys.Where(k =>
|
||||
@@ -122,7 +124,7 @@
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_keys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
_keys = (await ApiKeyAdmin.ListAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -131,20 +133,22 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static string MaskKeyValue(string keyValue)
|
||||
// Show a short, recognizable prefix of the opaque KeyId rather than the full 32-char value.
|
||||
private static string TruncateKeyId(string keyId)
|
||||
{
|
||||
if (keyValue.Length <= 8) return new string('*', keyValue.Length);
|
||||
return keyValue[..4] + new string('*', keyValue.Length - 8) + keyValue[^4..];
|
||||
if (string.IsNullOrEmpty(keyId)) return keyId;
|
||||
return keyId.Length <= 12 ? keyId : keyId[..12] + "…";
|
||||
}
|
||||
|
||||
private async Task ToggleKey(ApiKey key)
|
||||
private async Task ToggleKey(InboundApiKeyInfo key)
|
||||
{
|
||||
try
|
||||
{
|
||||
key.IsEnabled = !key.IsEnabled;
|
||||
await InboundApiRepository.UpdateApiKeyAsync(key);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(key.IsEnabled ? "enabled" : "disabled")}.");
|
||||
var newEnabled = !key.Enabled;
|
||||
// The seam persists; there is no separate SaveChangesAsync.
|
||||
await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
|
||||
_toast.ShowSuccess($"API key '{key.Name}' {(newEnabled ? "enabled" : "disabled")}.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -152,7 +156,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteKey(ApiKey key)
|
||||
private async Task DeleteKey(InboundApiKeyInfo key)
|
||||
{
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Delete API Key",
|
||||
@@ -162,8 +166,7 @@
|
||||
|
||||
try
|
||||
{
|
||||
await InboundApiRepository.DeleteApiKeyAsync(key.Id);
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
await ApiKeyAdmin.DeleteAsync(key.KeyId);
|
||||
_toast.ShowSuccess($"API key '{key.Name}' deleted.");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
@page "/"
|
||||
@attribute [Authorize]
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -108,7 +109,7 @@
|
||||
_siteCount = (await SiteRepository.GetAllSitesAsync()).Count;
|
||||
_dataConnectionCount = (await SiteRepository.GetAllDataConnectionsAsync()).Count;
|
||||
_templateCount = (await TemplateEngineRepository.GetAllTemplatesAsync()).Count;
|
||||
_apiKeyCount = (await InboundApiRepository.GetAllApiKeysAsync()).Count;
|
||||
_apiKeyCount = (await ApiKeyAdmin.ListAsync()).Count;
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
|
||||
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||||
@inject IInboundApiRepository InboundApiRepository
|
||||
@inject IInboundApiKeyAdmin ApiKeyAdmin
|
||||
@inject ScriptAnalysis.ScriptAnalysisService AnalysisService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@@ -44,14 +47,14 @@
|
||||
<div class="border rounded p-2" style="max-height: 220px; overflow-y: auto;">
|
||||
@foreach (var key in _allKeys)
|
||||
{
|
||||
var checkboxId = $"approved-key-{key.Id}";
|
||||
var checkboxId = $"approved-key-{key.KeyId}";
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="@checkboxId"
|
||||
checked="@_selectedKeyIds.Contains(key.Id)"
|
||||
@onchange="e => ToggleKey(key.Id, (bool)e.Value!)" />
|
||||
checked="@_selectedKeyIds.Contains(key.KeyId)"
|
||||
@onchange="e => ToggleKey(key.KeyId, (bool)e.Value!)" />
|
||||
<label class="form-check-label" for="@checkboxId">
|
||||
@key.Name
|
||||
@if (!key.IsEnabled)
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
@@ -195,9 +198,15 @@
|
||||
private IReadOnlyList<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker> _markers
|
||||
= Array.Empty<ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.DiagnosticMarker>();
|
||||
|
||||
// Inbound-API key re-arch (C3): the approved-keys list is driven by the IInboundApiKeyAdmin
|
||||
// seam, not ApiMethod.ApprovedApiKeyIds. The ApiMethod entity itself (name/script/params/etc.)
|
||||
// still lives on IInboundApiRepository — only the key↔method approval relationship moved to
|
||||
// per-key method-scopes. Keys are identified by an opaque string KeyId.
|
||||
private ApiMethod? _existing;
|
||||
private List<ApiKey> _allKeys = new();
|
||||
private HashSet<int> _selectedKeyIds = new();
|
||||
private List<InboundApiKeyInfo> _allKeys = new();
|
||||
private HashSet<string> _selectedKeyIds = new(StringComparer.Ordinal);
|
||||
// Keys approved for this method when the form loaded (empty on create), for diffing on save.
|
||||
private HashSet<string> _initialKeyIds = new(StringComparer.Ordinal);
|
||||
|
||||
private bool _showTestRun;
|
||||
private bool _running;
|
||||
@@ -209,7 +218,8 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
_allKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList();
|
||||
// All keys come from the seam (hash-free projection).
|
||||
_allKeys = (await ApiKeyAdmin.ListAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
|
||||
@@ -225,7 +235,10 @@
|
||||
_timeoutSeconds = _existing.TimeoutSeconds;
|
||||
_params = _existing.ParameterDefinitions;
|
||||
_returns = _existing.ReturnDefinition;
|
||||
_selectedKeyIds = ParseApprovedKeyIds(_existing.ApprovedApiKeyIds);
|
||||
// Seed approved keys from the seam: which keys' scopes contain this method.
|
||||
var keysForMethod = await ApiKeyAdmin.GetKeysForMethodAsync(_existing.Name);
|
||||
_initialKeyIds = new HashSet<string>(keysForMethod, StringComparer.Ordinal);
|
||||
_selectedKeyIds = new HashSet<string>(_initialKeyIds, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
@@ -233,25 +246,12 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private static HashSet<int> ParseApprovedKeyIds(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new HashSet<int>();
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private void ToggleKey(int keyId, bool isChecked)
|
||||
private void ToggleKey(string keyId, bool isChecked)
|
||||
{
|
||||
if (isChecked) _selectedKeyIds.Add(keyId);
|
||||
else _selectedKeyIds.Remove(keyId);
|
||||
}
|
||||
|
||||
private string? SerializeApprovedKeyIds() =>
|
||||
_selectedKeyIds.Count == 0 ? null : string.Join(",", _selectedKeyIds.OrderBy(id => id));
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_formError = null;
|
||||
@@ -263,15 +263,18 @@
|
||||
|
||||
try
|
||||
{
|
||||
var approvedKeyIds = SerializeApprovedKeyIds();
|
||||
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
|
||||
// key scopes against it. The method entity stays in SQL Server; we leave the
|
||||
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
|
||||
string methodName;
|
||||
if (_existing != null)
|
||||
{
|
||||
_existing.Script = _script;
|
||||
_existing.TimeoutSeconds = _timeoutSeconds;
|
||||
_existing.ParameterDefinitions = _params?.Trim();
|
||||
_existing.ReturnDefinition = _returns?.Trim();
|
||||
_existing.ApprovedApiKeyIds = approvedKeyIds;
|
||||
await InboundApiRepository.UpdateApiMethodAsync(_existing);
|
||||
methodName = _existing.Name;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -279,17 +282,65 @@
|
||||
{
|
||||
TimeoutSeconds = _timeoutSeconds,
|
||||
ParameterDefinitions = _params?.Trim(),
|
||||
ReturnDefinition = _returns?.Trim(),
|
||||
ApprovedApiKeyIds = approvedKeyIds
|
||||
ReturnDefinition = _returns?.Trim()
|
||||
};
|
||||
await InboundApiRepository.AddApiMethodAsync(m);
|
||||
methodName = m.Name;
|
||||
}
|
||||
await InboundApiRepository.SaveChangesAsync();
|
||||
|
||||
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
|
||||
if (!await ReconcileKeyScopesAsync(methodName)) return;
|
||||
|
||||
NavigationManager.NavigateTo("/design/external-systems");
|
||||
}
|
||||
catch (Exception ex) { _formError = ex.Message; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes this method's name into / out of each affected key's scope set. Returns false
|
||||
/// (leaving a form error) when a revoke would empty a key's scopes — the server rejects
|
||||
/// empty scope sets, so we abort rather than push one. Scopes are read fresh per affected
|
||||
/// key so we never clobber unrelated method-scopes.
|
||||
/// </summary>
|
||||
private async Task<bool> ReconcileKeyScopesAsync(string methodName)
|
||||
{
|
||||
var affected = _selectedKeyIds.Except(_initialKeyIds, StringComparer.Ordinal)
|
||||
.Concat(_initialKeyIds.Except(_selectedKeyIds, StringComparer.Ordinal))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Read each affected key's CURRENT full scope set so add/remove preserves other methods.
|
||||
var currentMethodsByKey = new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
foreach (var keyId in affected)
|
||||
{
|
||||
currentMethodsByKey[keyId] = await ApiKeyAdmin.GetMethodsForKeyAsync(keyId);
|
||||
}
|
||||
|
||||
var keyNamesById = _allKeys.ToDictionary(k => k.KeyId, k => k.Name, StringComparer.Ordinal);
|
||||
|
||||
var plan = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName, _selectedKeyIds, _initialKeyIds, currentMethodsByKey, keyNamesById);
|
||||
|
||||
// Empty-last-scope guard: a key cannot end up with zero scopes (server rejects it).
|
||||
if (plan.EmptyScopeKeyNames.Count > 0)
|
||||
{
|
||||
var names = string.Join(", ", plan.EmptyScopeKeyNames);
|
||||
_formError =
|
||||
$"Cannot revoke this method from key(s) [{names}] — it would leave them with no methods. " +
|
||||
"Disable or delete the key instead, or grant it another method first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var update in plan.Updates)
|
||||
{
|
||||
await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
|
||||
}
|
||||
|
||||
// Selection is now the baseline (matters if save is retried without reload).
|
||||
_initialKeyIds = new HashSet<string>(_selectedKeyIds, StringComparer.Ordinal);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void GoBack() => NavigationManager.NavigateTo("/design/external-systems");
|
||||
|
||||
private void ToggleTestRunPanel() => _showTestRun = !_showTestRun;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helper for the inbound-API-key re-arch (C3) on the API-method form. The approval
|
||||
/// relationship moved from <c>ApiMethod.ApprovedApiKeyIds</c> (a CSV on the method) onto
|
||||
/// per-key method-scopes managed through <c>IInboundApiKeyAdmin</c>. When an operator edits
|
||||
/// the "Approved API Keys" list for one method, we must reconcile that method's NAME into (or
|
||||
/// out of) each affected key's scope set — without touching keys whose membership did not change.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Factored out of the Blazor component so the add/remove/empty-guard logic is unit-testable
|
||||
/// independent of bUnit rendering.
|
||||
/// </remarks>
|
||||
public static class ApiMethodKeyScopeReconciler
|
||||
{
|
||||
/// <summary>
|
||||
/// The recomputed scope set for one key that must be persisted via
|
||||
/// <c>IInboundApiKeyAdmin.SetMethodsAsync</c>.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">The key whose scopes changed.</param>
|
||||
/// <param name="NewMethods">The full new method-scope set for that key (always non-empty —
|
||||
/// the empty case is reported as a conflict instead).</param>
|
||||
public sealed record KeyScopeUpdate(string KeyId, IReadOnlyList<string> NewMethods);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of reconciling one method's approved-key selection against the keys' current scopes.
|
||||
/// </summary>
|
||||
/// <param name="Updates">Per-affected-key new scope sets to push. Empty when nothing changed.</param>
|
||||
/// <param name="EmptyScopeKeyNames">Display names of keys for which revoking this method would
|
||||
/// leave zero scopes. When this is non-empty the caller MUST NOT apply <see cref="Updates"/> —
|
||||
/// it should surface a validation error and abort, because the server rejects empty scope sets.</param>
|
||||
public sealed record ReconcileResult(
|
||||
IReadOnlyList<KeyScopeUpdate> Updates,
|
||||
IReadOnlyList<string> EmptyScopeKeyNames);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the scope edits needed so that exactly the keys in <paramref name="selectedKeyIds"/>
|
||||
/// are scoped to <paramref name="methodName"/>.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The method whose approval set is being edited. Must already exist
|
||||
/// in the store (save the ApiMethod entity FIRST so its name resolves).</param>
|
||||
/// <param name="selectedKeyIds">Keys the operator wants approved for this method (after edit).</param>
|
||||
/// <param name="initialKeyIds">Keys that were approved for this method when the form loaded
|
||||
/// (empty on create).</param>
|
||||
/// <param name="currentMethodsByKey">Each affected key's CURRENT full scope set, keyed by KeyId.
|
||||
/// Read fresh from the seam right before reconciling so concurrent edits do not get clobbered.</param>
|
||||
/// <param name="keyNamesById">Display names by KeyId, for human-readable empty-scope messages.</param>
|
||||
public static ReconcileResult Reconcile(
|
||||
string methodName,
|
||||
IReadOnlySet<string> selectedKeyIds,
|
||||
IReadOnlySet<string> initialKeyIds,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> currentMethodsByKey,
|
||||
IReadOnlyDictionary<string, string> keyNamesById)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(methodName);
|
||||
ArgumentNullException.ThrowIfNull(selectedKeyIds);
|
||||
ArgumentNullException.ThrowIfNull(initialKeyIds);
|
||||
ArgumentNullException.ThrowIfNull(currentMethodsByKey);
|
||||
ArgumentNullException.ThrowIfNull(keyNamesById);
|
||||
|
||||
var added = selectedKeyIds.Except(initialKeyIds, StringComparer.Ordinal);
|
||||
var removed = initialKeyIds.Except(selectedKeyIds, StringComparer.Ordinal);
|
||||
|
||||
var updates = new List<KeyScopeUpdate>();
|
||||
var emptyScopeKeyNames = new List<string>();
|
||||
|
||||
// Approving: add this method's name to the key's current scope set (idempotent).
|
||||
foreach (var keyId in added)
|
||||
{
|
||||
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
|
||||
? m
|
||||
: (IReadOnlyList<string>)Array.Empty<string>();
|
||||
var next = new HashSet<string>(current, StringComparer.Ordinal) { methodName };
|
||||
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
|
||||
}
|
||||
|
||||
// Revoking: remove this method's name. If that empties the key, it is a conflict —
|
||||
// the server rejects empty scope sets, so we report it and the caller must abort.
|
||||
foreach (var keyId in removed)
|
||||
{
|
||||
var current = currentMethodsByKey.TryGetValue(keyId, out var m)
|
||||
? m
|
||||
: (IReadOnlyList<string>)Array.Empty<string>();
|
||||
var next = new HashSet<string>(current, StringComparer.Ordinal);
|
||||
next.Remove(methodName);
|
||||
|
||||
if (next.Count == 0)
|
||||
{
|
||||
emptyScopeKeyNames.Add(
|
||||
keyNamesById.TryGetValue(keyId, out var name) ? name : keyId);
|
||||
continue;
|
||||
}
|
||||
|
||||
updates.Add(new KeyScopeUpdate(keyId, next.OrderBy(s => s, StringComparer.Ordinal).ToList()));
|
||||
}
|
||||
|
||||
return new ReconcileResult(updates, emptyScopeKeyNames);
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,37 @@ using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.JSInterop;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ApiKeyForm = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.ApiKeyForm;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page. The chip
|
||||
/// routes operators into the central Audit Log pre-filtered by Actor = ApiKey.Name
|
||||
/// AND Channel = ApiInbound (no other channel uses the key name as actor, but
|
||||
/// the explicit channel scope keeps deep links tight). Create mode suppresses
|
||||
/// the link — there's no API key to drill into yet.
|
||||
/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page, re-wired for the
|
||||
/// inbound-API-key re-arch (C3) onto the <see cref="IInboundApiKeyAdmin"/> seam. The chip
|
||||
/// routes operators into the central Audit Log pre-filtered by Actor = key Name AND
|
||||
/// Channel = ApiInbound (no other channel uses the key name as actor, but the explicit
|
||||
/// channel scope keeps deep links tight). Create mode suppresses the link — there's no API
|
||||
/// key to drill into yet. Also covers the new seam-driven list and one-time-token panel.
|
||||
/// </summary>
|
||||
public class ApiKeyFormAuditDrillinTests : BunitContext
|
||||
{
|
||||
private readonly IInboundApiKeyAdmin _admin = Substitute.For<IInboundApiKeyAdmin>();
|
||||
private readonly IInboundApiRepository _repo = Substitute.For<IInboundApiRepository>();
|
||||
|
||||
public ApiKeyFormAuditDrillinTests()
|
||||
{
|
||||
Services.AddSingleton(_admin);
|
||||
Services.AddSingleton(_repo);
|
||||
|
||||
// Methods still come from the SQL Server repository; default to none unless a test overrides.
|
||||
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "admin"),
|
||||
@@ -38,16 +45,21 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
}
|
||||
|
||||
private static InboundApiKeyInfo Key(
|
||||
string keyId, string name, bool enabled = true, params string[] methods) =>
|
||||
new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null);
|
||||
|
||||
[Fact]
|
||||
public void EditPage_HasRecentAuditActivityLink_WithActorAndApiInboundChannel()
|
||||
{
|
||||
var key = ApiKey.FromHash("Orders-Integration", "k-hash");
|
||||
key.Id = 11;
|
||||
_repo.GetApiKeyByIdAsync(11, Arg.Any<CancellationToken>()).Returns(key);
|
||||
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
const string keyId = "abc123def456";
|
||||
var info = Key(keyId, "Orders-Integration", true, "PlaceOrder");
|
||||
_admin.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(new[] { info }));
|
||||
_admin.GetMethodsForKeyAsync(keyId, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "PlaceOrder" }));
|
||||
|
||||
var cut = Render<ApiKeyForm>(p => p.Add(c => c.Id, 11));
|
||||
var cut = Render<ApiKeyForm>(p => p.Add(c => c.KeyId, keyId));
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
@@ -62,6 +74,9 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
|
||||
[Fact]
|
||||
public void CreatePage_HasNoRecentAuditActivityLink()
|
||||
{
|
||||
_admin.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(Array.Empty<InboundApiKeyInfo>()));
|
||||
|
||||
var cut = Render<ApiKeyForm>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
@@ -69,4 +84,56 @@ public class ApiKeyFormAuditDrillinTests : BunitContext
|
||||
Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_WithMethodSelected_ShowsOneTimeTokenAndKeyId()
|
||||
{
|
||||
_admin.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(Array.Empty<InboundApiKeyInfo>()));
|
||||
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(
|
||||
new List<ApiMethod> { new("PlaceOrder", "return null;") { Id = 1 } }));
|
||||
_admin.CreateAsync("MES-Production", Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new InboundApiKeyCreated("new-key-id", "sbk_new-key-id_secret")));
|
||||
|
||||
var cut = Render<ApiKeyForm>();
|
||||
|
||||
// Fill name, check the only method, save.
|
||||
cut.WaitForElement("input[type=text]").Change("MES-Production");
|
||||
cut.Find("#method-access-1").Change(true);
|
||||
await cut.Find("button.btn-success").ClickAsync(new());
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
var token = cut.Find("[data-test=\"created-token\"]");
|
||||
Assert.Contains("sbk_new-key-id_secret", token.TextContent);
|
||||
Assert.Contains("new-key-id", cut.Markup);
|
||||
Assert.Contains("will not be shown again", cut.Markup);
|
||||
});
|
||||
|
||||
await _admin.Received(1).CreateAsync(
|
||||
"MES-Production",
|
||||
Arg.Is<IReadOnlyCollection<string>>(m => m.Count == 1 && m.Contains("PlaceOrder")),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePage_WithNoMethodSelected_ShowsValidationError_AndDoesNotCreate()
|
||||
{
|
||||
_admin.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(Array.Empty<InboundApiKeyInfo>()));
|
||||
_repo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(
|
||||
new List<ApiMethod> { new("PlaceOrder", "return null;") { Id = 1 } }));
|
||||
|
||||
var cut = Render<ApiKeyForm>();
|
||||
cut.WaitForElement("input[type=text]").Change("MES-Production");
|
||||
await cut.Find("button.btn-success").ClickAsync(new());
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Contains("at least one API method", cut.Markup));
|
||||
|
||||
await _admin.DidNotReceive().CreateAsync(
|
||||
Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ApiKeys = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.ApiKeys;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound-API key re-arch (C3): the API Keys list page now reads keys from the
|
||||
/// <see cref="IInboundApiKeyAdmin"/> seam (string KeyId + method-scopes) instead of the SQL
|
||||
/// Server ApiKey entity. There is no retrievable hash, so the old masked Key-Hash column is gone.
|
||||
/// </summary>
|
||||
public class ApiKeysListPageTests : BunitContext
|
||||
{
|
||||
private readonly IInboundApiKeyAdmin _admin = Substitute.For<IInboundApiKeyAdmin>();
|
||||
|
||||
public ApiKeysListPageTests()
|
||||
{
|
||||
Services.AddSingleton(_admin);
|
||||
Services.AddSingleton<IDialogService>(Substitute.For<IDialogService>());
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("Username", "admin"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Admin"),
|
||||
};
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
}
|
||||
|
||||
private static InboundApiKeyInfo Key(
|
||||
string keyId, string name, bool enabled, params string[] methods) =>
|
||||
new(keyId, name, enabled, methods, DateTimeOffset.UnixEpoch, null);
|
||||
|
||||
[Fact]
|
||||
public void List_RendersKeysFromSeam_WithNoKeyHashColumn()
|
||||
{
|
||||
_admin.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(new[]
|
||||
{
|
||||
Key("aaaaaaaaaaaa1111", "Orders-Integration", true, "PlaceOrder", "GetStatus"),
|
||||
Key("bbbbbbbbbbbb2222", "Disabled-Key", false, "Ping"),
|
||||
}));
|
||||
|
||||
var cut = Render<ApiKeys>();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
// Both key names render.
|
||||
Assert.Contains("Orders-Integration", cut.Markup);
|
||||
Assert.Contains("Disabled-Key", cut.Markup);
|
||||
|
||||
// The disabled key carries the Disabled badge.
|
||||
Assert.Contains("Disabled", cut.Markup);
|
||||
|
||||
// No Key-Hash column header.
|
||||
var headers = cut.FindAll("th").Select(h => h.TextContent.Trim()).ToList();
|
||||
Assert.DoesNotContain(headers, h => h.Contains("Hash", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(headers, h => h == "Key ID");
|
||||
Assert.Contains(headers, h => h == "Methods");
|
||||
|
||||
// KeyId renders truncated (first 12 chars + ellipsis), not the full value.
|
||||
Assert.Contains("aaaaaaaaaaaa…", cut.Markup);
|
||||
|
||||
// The Methods column shows the per-key scope count (2 for the first key).
|
||||
var cells = cut.FindAll("td").Select(c => c.TextContent.Trim()).ToList();
|
||||
Assert.Contains("2", cells);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleKey_CallsSetEnabledOnSeam()
|
||||
{
|
||||
_admin.ListAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<InboundApiKeyInfo>>(new[]
|
||||
{
|
||||
Key("aaaaaaaaaaaa1111", "Orders-Integration", true, "PlaceOrder"),
|
||||
}));
|
||||
|
||||
var cut = Render<ApiKeys>();
|
||||
|
||||
// The dropdown's first item toggles enabled state (currently enabled -> Disable).
|
||||
var disableButton = cut.WaitForElement("button.dropdown-item");
|
||||
await disableButton.ClickAsync(new());
|
||||
|
||||
await _admin.Received(1).SetEnabledAsync("aaaaaaaaaaaa1111", false, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound-API key re-arch (C3): unit tests for <see cref="ApiMethodKeyScopeReconciler"/>, which
|
||||
/// inverts the API-method form's "Approved API Keys" selection into per-key method-scope edits.
|
||||
/// Covers approve, revoke (preserving other scopes), the empty-last-scope guard, and the no-op case.
|
||||
/// </summary>
|
||||
public sealed class ApiMethodKeyScopeReconcilerTests
|
||||
{
|
||||
private static IReadOnlyDictionary<string, IReadOnlyList<string>> Current(
|
||||
params (string KeyId, string[] Methods)[] entries) =>
|
||||
entries.ToDictionary(
|
||||
e => e.KeyId,
|
||||
e => (IReadOnlyList<string>)e.Methods.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> Names(params (string KeyId, string Name)[] entries) =>
|
||||
entries.ToDictionary(e => e.KeyId, e => e.Name, StringComparer.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Approve_AddsMethodToKey_PreservingExistingScopes()
|
||||
{
|
||||
var result = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName: "PlaceOrder",
|
||||
selectedKeyIds: new HashSet<string> { "k1" },
|
||||
initialKeyIds: new HashSet<string>(),
|
||||
currentMethodsByKey: Current(("k1", new[] { "GetStatus" })),
|
||||
keyNamesById: Names(("k1", "Key One")));
|
||||
|
||||
Assert.Empty(result.EmptyScopeKeyNames);
|
||||
var update = Assert.Single(result.Updates);
|
||||
Assert.Equal("k1", update.KeyId);
|
||||
Assert.Equal(new[] { "GetStatus", "PlaceOrder" }, update.NewMethods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approve_KeyWithNoExistingScopes_GetsJustThisMethod()
|
||||
{
|
||||
var result = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName: "PlaceOrder",
|
||||
selectedKeyIds: new HashSet<string> { "k1" },
|
||||
initialKeyIds: new HashSet<string>(),
|
||||
currentMethodsByKey: Current(("k1", Array.Empty<string>())),
|
||||
keyNamesById: Names(("k1", "Key One")));
|
||||
|
||||
var update = Assert.Single(result.Updates);
|
||||
Assert.Equal(new[] { "PlaceOrder" }, update.NewMethods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoke_RemovesMethod_LeavingOtherScopesIntact()
|
||||
{
|
||||
var result = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName: "PlaceOrder",
|
||||
selectedKeyIds: new HashSet<string>(),
|
||||
initialKeyIds: new HashSet<string> { "k1" },
|
||||
currentMethodsByKey: Current(("k1", new[] { "PlaceOrder", "GetStatus" })),
|
||||
keyNamesById: Names(("k1", "Key One")));
|
||||
|
||||
Assert.Empty(result.EmptyScopeKeyNames);
|
||||
var update = Assert.Single(result.Updates);
|
||||
Assert.Equal("k1", update.KeyId);
|
||||
Assert.Equal(new[] { "GetStatus" }, update.NewMethods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoke_LastScope_ReportedAsEmptyConflict_AndNotInUpdates()
|
||||
{
|
||||
var result = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName: "PlaceOrder",
|
||||
selectedKeyIds: new HashSet<string>(),
|
||||
initialKeyIds: new HashSet<string> { "k1" },
|
||||
currentMethodsByKey: Current(("k1", new[] { "PlaceOrder" })),
|
||||
keyNamesById: Names(("k1", "Key One")));
|
||||
|
||||
Assert.Empty(result.Updates);
|
||||
var emptyName = Assert.Single(result.EmptyScopeKeyNames);
|
||||
Assert.Equal("Key One", emptyName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_ApproveOneRevokeAnother_ProducesBothUpdates()
|
||||
{
|
||||
var result = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName: "PlaceOrder",
|
||||
selectedKeyIds: new HashSet<string> { "k2" }, // approve k2
|
||||
initialKeyIds: new HashSet<string> { "k1" }, // revoke k1
|
||||
currentMethodsByKey: Current(
|
||||
("k1", new[] { "PlaceOrder", "GetStatus" }),
|
||||
("k2", new[] { "Ping" })),
|
||||
keyNamesById: Names(("k1", "Key One"), ("k2", "Key Two")));
|
||||
|
||||
Assert.Empty(result.EmptyScopeKeyNames);
|
||||
Assert.Equal(2, result.Updates.Count);
|
||||
|
||||
var k1 = result.Updates.Single(u => u.KeyId == "k1");
|
||||
Assert.Equal(new[] { "GetStatus" }, k1.NewMethods);
|
||||
|
||||
var k2 = result.Updates.Single(u => u.KeyId == "k2");
|
||||
Assert.Equal(new[] { "Ping", "PlaceOrder" }, k2.NewMethods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoChange_ProducesNoUpdates()
|
||||
{
|
||||
var result = ApiMethodKeyScopeReconciler.Reconcile(
|
||||
methodName: "PlaceOrder",
|
||||
selectedKeyIds: new HashSet<string> { "k1" },
|
||||
initialKeyIds: new HashSet<string> { "k1" },
|
||||
currentMethodsByKey: Current(("k1", new[] { "PlaceOrder" })),
|
||||
keyNamesById: Names(("k1", "Key One")));
|
||||
|
||||
Assert.Empty(result.Updates);
|
||||
Assert.Empty(result.EmptyScopeKeyNames);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user