feat(control): add operator web console for nodes, links, and enrollment code generation #59

Merged
ochenstarik-ui merged 3 commits from antigravity/web-console into main 2026-08-17 19:47:39 +00:00
6 changed files with 1652 additions and 0 deletions

View file

@ -573,6 +573,13 @@ agents.MapPost("/provisioning/jobs/{id}/execution-grant", async (
} }
}); });
var console = app.MapGroup("/").RequireAuthorization("Operator");
console.MapGet("/", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8"));
console.MapGet("/index.html", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8"));
console.MapGet("/style.css", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "style.css", "text/css; charset=utf-8"));
console.MapGet("/app.js", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "app.js", "application/javascript; charset=utf-8"));
console.MapGet("/console", (IWebHostEnvironment env) => GetWebConsoleAsset(env, "index.html", "text/html; charset=utf-8"));
var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator"); var control = app.MapGroup("/api/v1/control").RequireAuthorization("Operator");
control.MapPost("/certificates/renew", async ( control.MapPost("/certificates/renew", async (
CertificateRenewalRequest request, CertificateRenewalRequest request,
@ -1054,6 +1061,36 @@ automation.MapGet("/links", async (
await app.RunAsync(); await app.RunAsync();
return 0; return 0;
static IResult GetWebConsoleAsset(IWebHostEnvironment env, string fileName, string contentType)
{
var webRoot = env.WebRootPath;
if (string.IsNullOrWhiteSpace(webRoot))
{
webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
}
var filePath = Path.Combine(webRoot, fileName);
if (File.Exists(filePath))
{
return Results.File(filePath, contentType);
}
var assembly = typeof(Program).Assembly;
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(name => name.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));
if (resourceName is not null)
{
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is not null)
{
return Results.Stream(stream, contentType);
}
}
return Results.NotFound();
}
static async Task<IResult> ChangeProvisioningJobAsync( static async Task<IResult> ChangeProvisioningJobAsync(
string id, string id,
ProvisioningJobCommandRequest request, ProvisioningJobCommandRequest request,

View file

@ -16,5 +16,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> <None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<EmbeddedResource Include="wwwroot\**" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,395 @@
/**
* SMM Operator Web Console
*/
(function () {
'use strict';
// DOM Elements
const nodesTableBody = document.getElementById('nodes-table-body');
const linksTableBody = document.getElementById('links-table-body');
const nodesCountBadge = document.getElementById('nodes-count');
const linksCountBadge = document.getElementById('links-count');
const nodesEmpty = document.getElementById('nodes-empty');
const linksEmpty = document.getElementById('links-empty');
const lastUpdatedText = document.getElementById('last-updated-text');
const globalAlert = document.getElementById('global-alert');
// Action Elements
const refreshBtn = document.getElementById('refresh-btn');
const addNodeBtn = document.getElementById('add-node-btn');
// Modal Elements
const modalBackdrop = document.getElementById('add-node-modal');
const modalCloseBtn = document.getElementById('modal-close-btn');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const enrollmentForm = document.getElementById('enrollment-form');
const nodeIdInput = document.getElementById('node-id-input');
const generateCodeBtn = document.getElementById('generate-code-btn');
const modalError = document.getElementById('modal-error');
const enrollmentResult = document.getElementById('enrollment-result');
const resultDoneBtn = document.getElementById('result-done-btn');
// Result Displays & Copy Buttons
const caFingerprintDisplay = document.getElementById('ca-fingerprint-display');
const enrollmentCodeDisplay = document.getElementById('enrollment-code-display');
const copyFingerprintBtn = document.getElementById('copy-fingerprint-btn');
const copyCodeBtn = document.getElementById('copy-code-btn');
const countdownTimer = document.getElementById('countdown-timer');
// State
let countdownInterval = null;
let autoRefreshInterval = null;
let isModalOpen = false;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
bindEvents();
loadDashboardData();
startAutoRefresh();
});
function bindEvents() {
refreshBtn.addEventListener('click', () => {
loadDashboardData(true);
});
addNodeBtn.addEventListener('click', openAddNodeModal);
modalCloseBtn.addEventListener('click', closeAddNodeModal);
modalCancelBtn.addEventListener('click', closeAddNodeModal);
resultDoneBtn.addEventListener('click', closeAddNodeModal);
modalBackdrop.addEventListener('click', (e) => {
if (e.target === modalBackdrop) {
closeAddNodeModal();
}
});
enrollmentForm.addEventListener('submit', handleEnrollmentSubmit);
copyCodeBtn.addEventListener('click', () => {
copyToClipboard(enrollmentCodeDisplay.value, copyCodeBtn, 'Скопировать код');
});
copyFingerprintBtn.addEventListener('click', () => {
copyToClipboard(caFingerprintDisplay.textContent, copyFingerprintBtn, 'Копировать отпечаток');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isModalOpen) {
closeAddNodeModal();
}
});
}
function startAutoRefresh() {
if (autoRefreshInterval) clearInterval(autoRefreshInterval);
autoRefreshInterval = setInterval(() => {
if (!isModalOpen) {
loadDashboardData(false);
}
}, 10000);
}
async function loadDashboardData(showLoadingIndicator = false) {
if (showLoadingIndicator) {
refreshBtn.disabled = true;
refreshBtn.querySelector('.btn-icon').textContent = '⏳';
}
try {
const [agentsRes, linksRes] = await Promise.all([
fetch('/api/v1/control/agents', { headers: { Accept: 'application/json' } }),
fetch('/api/v1/control/links', { headers: { Accept: 'application/json' } })
]);
if (agentsRes.status === 401 || linksRes.status === 401) {
showGlobalAlert('Ошибка аутентификации: требуется клиентский сертификат роли Operator.', 'error');
return;
}
if (agentsRes.status === 403 || linksRes.status === 403) {
showGlobalAlert('Доступ запрещён: сертификат не имеет роли Operator.', 'error');
return;
}
if (!agentsRes.ok || !linksRes.ok) {
throw new Error(`Ошибка загрузки данных (${agentsRes.status} / ${linksRes.status})`);
}
const agents = await agentsRes.json();
const links = await linksRes.json();
renderNodes(agents);
renderLinks(links);
hideGlobalAlert();
const now = new Date();
lastUpdatedText.textContent = `Обновлено в ${now.toLocaleTimeString()}`;
} catch (err) {
console.error('Failed to load dashboard data:', err);
showGlobalAlert(`Не удалось обновить данные: ${err.message}`, 'error');
} finally {
if (showLoadingIndicator) {
refreshBtn.disabled = false;
refreshBtn.querySelector('.btn-icon').textContent = '↻';
}
}
}
function renderNodes(agents) {
const list = Array.isArray(agents) ? agents : [];
nodesCountBadge.textContent = list.length;
if (list.length === 0) {
nodesTableBody.innerHTML = '';
nodesEmpty.classList.remove('hidden');
return;
}
nodesEmpty.classList.add('hidden');
nodesTableBody.innerHTML = list.map(agent => {
const statusClass = (agent.status || 'unknown').toLowerCase();
const lastSeen = formatTimestamp(agent.lastSeenAt);
const certInfo = formatCertInfo(agent);
return `
<tr>
<td class="node-name-cell">${escapeHtml(agent.nodeId || agent.name || '—')}</td>
<td><span class="status-badge ${escapeHtml(statusClass)}">${escapeHtml(agent.status || 'Unknown')}</span></td>
<td>${escapeHtml(agent.agentVersion || '—')}</td>
<td>${lastSeen}</td>
<td>${certInfo}</td>
</tr>
`;
}).join('');
}
function renderLinks(links) {
const list = Array.isArray(links) ? links : [];
linksCountBadge.textContent = list.length;
if (list.length === 0) {
linksTableBody.innerHTML = '';
linksEmpty.classList.remove('hidden');
return;
}
linksEmpty.classList.add('hidden');
linksTableBody.innerHTML = list.map(link => {
const desiredClass = (link.desiredState || 'unknown').toLowerCase();
const actualClass = (link.actualState || 'unknown').toLowerCase();
const expires = formatTimestamp(link.expiresAt);
return `
<tr>
<td class="link-id-cell" title="${escapeHtml(link.id)}">${escapeHtml((link.id || '').substring(0, 8))}...</td>
<td class="node-name-cell">${escapeHtml(link.sourceNodeId || '—')}</td>
<td class="node-name-cell">${escapeHtml(link.targetNodeId || '—')}</td>
<td>${escapeHtml(link.protocol || 'TCP')}/${escapeHtml(String(link.port || 0))}</td>
<td><span class="status-badge ${escapeHtml(desiredClass)}">${escapeHtml(link.desiredState || '—')}</span></td>
<td><span class="status-badge ${escapeHtml(actualClass)}">${escapeHtml(link.actualState || '—')}</span></td>
<td>${expires}</td>
</tr>
`;
}).join('');
}
async function handleEnrollmentSubmit(e) {
e.preventDefault();
const nodeId = (nodeIdInput.value || '').trim();
if (!nodeId || !/^[a-z0-9-]{1,63}$/.test(nodeId)) {
showModalError('Имя узла должно содержать от 1 до 63 строчных латинских букв, цифр или дефисов.');
return;
}
hideModalError();
generateCodeBtn.disabled = true;
generateCodeBtn.textContent = 'Генерация...';
try {
const res = await fetch(`/api/v1/control/agents/${encodeURIComponent(nodeId)}/enrollment-code`, {
method: 'POST',
headers: {
'Accept': 'application/json'
}
});
if (res.status === 401) {
showModalError('Требуется клиентский сертификат роли Operator.');
return;
}
if (res.status === 403) {
showModalError('Доступ запрещен: роль не является Operator.');
return;
}
if (res.status === 429) {
showModalError('Превышен лимит запросов на выпуск кодов (10/мин). Подождите перед повторной попыткой.');
return;
}
const data = await res.json();
if (!res.ok) {
const errorDetail = data?.detail || data?.title || 'Ошибка выпуска кода регистрации';
showModalError(errorDetail);
return;
}
displayEnrollmentResult(data);
} catch (err) {
console.error('Enrollment code creation error:', err);
showModalError(`Сетевая ошибка: ${err.message}`);
} finally {
generateCodeBtn.disabled = false;
generateCodeBtn.textContent = 'Сгенерировать код';
}
}
function displayEnrollmentResult(data) {
enrollmentForm.classList.add('hidden');
enrollmentResult.classList.remove('hidden');
caFingerprintDisplay.textContent = data.certificateAuthorityFingerprintSha256 || '—';
enrollmentCodeDisplay.value = data.code || '';
// Start 10-minute countdown
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : new Date(Date.now() + 10 * 60 * 1000);
startCountdown(expiresAt);
}
function startCountdown(targetDate) {
if (countdownInterval) clearInterval(countdownInterval);
function update() {
const now = new Date();
const remainingMs = targetDate.getTime() - now.getTime();
if (remainingMs <= 0) {
countdownTimer.textContent = '00:00 (истёк)';
countdownTimer.style.backgroundColor = 'var(--danger-bg)';
countdownTimer.style.color = 'var(--danger-text)';
clearInterval(countdownInterval);
return;
}
const totalSec = Math.floor(remainingMs / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
countdownTimer.textContent = `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
}
update();
countdownInterval = setInterval(update, 1000);
}
function openAddNodeModal() {
isModalOpen = true;
enrollmentForm.reset();
enrollmentForm.classList.remove('hidden');
enrollmentResult.classList.add('hidden');
hideModalError();
modalBackdrop.classList.remove('hidden');
nodeIdInput.focus();
}
function closeAddNodeModal() {
isModalOpen = false;
modalBackdrop.classList.add('hidden');
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
loadDashboardData();
}
async function copyToClipboard(text, btnElement, defaultLabel) {
if (!text) return;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
btnElement.textContent = '✓ Скопировано!';
btnElement.classList.add('btn-success');
setTimeout(() => {
btnElement.textContent = defaultLabel;
btnElement.classList.remove('btn-success');
}, 2000);
} catch (err) {
console.error('Copy failed:', err);
btnElement.textContent = 'Ошибка копирования';
setTimeout(() => {
btnElement.textContent = defaultLabel;
}, 2000);
}
}
function showModalError(msg) {
modalError.textContent = msg;
modalError.classList.remove('hidden');
}
function hideModalError() {
modalError.textContent = '';
modalError.classList.add('hidden');
}
function showGlobalAlert(msg, type = 'error') {
globalAlert.textContent = msg;
globalAlert.className = `alert alert-${type}`;
globalAlert.classList.remove('hidden');
}
function hideGlobalAlert() {
globalAlert.classList.add('hidden');
}
function formatTimestamp(isoString) {
if (!isoString) return '<span class="text-muted">—</span>';
const date = new Date(isoString);
if (isNaN(date.getTime())) return '<span class="text-muted">—</span>';
const diffSec = Math.floor((Date.now() - date.getTime()) / 1000);
if (diffSec < 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
if (diffSec < 60) return `${diffSec} сек. назад`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} мин. назад`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} ч. назад`;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatCertInfo(agent) {
if (typeof agent.certificateRemainingDays === 'number') {
const days = agent.certificateRemainingDays;
if (days < 0) return '<span class="status-badge failed">Истёк</span>';
if (days <= 7) return `<span class="status-badge pending">${days} дн.</span>`;
return `<span class="status-badge active">${days} дн.</span>`;
}
if (agent.certificateExpiresAt) {
const expDate = new Date(agent.certificateExpiresAt);
return expDate.toLocaleDateString();
}
return '<span class="text-muted">—</span>';
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
})();

View file

@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SMM — Консоль оператора</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-container">
<header class="app-header">
<div class="header-brand">
<div class="brand-badge">SMM</div>
<div class="brand-titles">
<h1>Консоль оператора</h1>
<span class="brand-subtitle">Server Monitor Manager</span>
</div>
</div>
<div class="header-actions">
<div class="status-indicator">
<span class="status-dot"></span>
<span id="connection-status">mTLS подключен</span>
</div>
<button id="refresh-btn" class="btn btn-secondary" title="Обновить данные">
<span class="btn-icon"></span>
<span>Обновить</span>
</button>
<button id="add-node-btn" class="btn btn-primary">
<span class="btn-icon">+</span>
<span>Добавить узел</span>
</button>
</div>
</header>
<main class="app-main">
<div id="global-alert" class="alert hidden" role="alert"></div>
<!-- Раздел: Узлы -->
<section class="dashboard-section" id="nodes-section">
<div class="section-header">
<div class="section-title-group">
<h2>Узлы мониторинга</h2>
<span id="nodes-count" class="counter-badge">0</span>
</div>
<span class="section-hint">Зарегистрированные агенты и их текущий статус</span>
</div>
<div class="table-container">
<table class="data-table" id="nodes-table">
<thead>
<tr>
<th>Имя узла</th>
<th>Состояние</th>
<th>Версия агента</th>
<th>Последний Heartbeat</th>
<th>Сертификат</th>
</tr>
</thead>
<tbody id="nodes-table-body">
<tr class="loading-row">
<td colspan="5">Загрузка списка узлов...</td>
</tr>
</tbody>
</table>
<div id="nodes-empty" class="empty-state hidden">
<div class="empty-state-icon">🖥️</div>
<div class="empty-state-text">Нет зарегистрированных узлов</div>
<p class="empty-state-subtext">Нажмите «Добавить узел», чтобы выпустить код регистрации и подключить первый сервер.</p>
</div>
</div>
</section>
<!-- Раздел: Сетевые связи (Links) -->
<section class="dashboard-section" id="links-section">
<div class="section-header">
<div class="section-title-group">
<h2>Сетевые связи (Links)</h2>
<span id="links-count" class="counter-badge">0</span>
</div>
<span class="section-hint">Политики взаимодействия между узлами mesh-сети</span>
</div>
<div class="table-container">
<table class="data-table" id="links-table">
<thead>
<tr>
<th>ID связи</th>
<th>Источник</th>
<th>Назначение</th>
<th>Протокол / Порт</th>
<th>Требуемое состояние</th>
<th>Фактическое состояние</th>
<th>Срок действия</th>
</tr>
</thead>
<tbody id="links-table-body">
<tr class="loading-row">
<td colspan="7">Загрузка сетевых связей...</td>
</tr>
</tbody>
</table>
<div id="links-empty" class="empty-state hidden">
<div class="empty-state-icon">🔗</div>
<div class="empty-state-text">Нет активных связей</div>
<p class="empty-state-subtext">Сетевые политики взаимодействия между узлами ещё не созданы.</p>
</div>
</div>
</section>
</main>
<footer class="app-footer">
<div class="footer-info">
<span>Server Monitor Manager v0.1.0</span>
<span class="footer-separator"></span>
<span>Доступ авторизован по сертификату роли Operator</span>
</div>
<div class="footer-timestamp">
<span id="last-updated-text">Синхронизировано</span>
</div>
</footer>
</div>
<!-- Модальное окно: Добавление узла -->
<div id="add-node-modal" class="modal-backdrop hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal-window">
<div class="modal-header">
<h3 id="modal-title">Выпуск кода регистрации узла</h3>
<button id="modal-close-btn" class="modal-close" aria-label="Закрыть">×</button>
</div>
<div class="modal-body">
<!-- Шаг 1: Форма ввода имени -->
<form id="enrollment-form">
<div class="form-group">
<label for="node-id-input">Имя нового узла (Node ID):</label>
<input
type="text"
id="node-id-input"
class="form-control"
placeholder="например: web-prod-01"
maxlength="63"
pattern="^[a-z0-9-]{1,63}$"
required
autocomplete="off"
spellcheck="false">
<span class="form-hint">От 1 до 63 символов: строчные латинские буквы, цифры и дефисы.</span>
</div>
<div id="modal-error" class="alert alert-error hidden" role="alert"></div>
<div class="form-actions">
<button type="button" id="modal-cancel-btn" class="btn btn-secondary">Отмена</button>
<button type="submit" id="generate-code-btn" class="btn btn-primary">
<span>Сгенерировать код</span>
</button>
</div>
</form>
<!-- Шаг 2: Результат генерации кода -->
<div id="enrollment-result" class="enrollment-result-container hidden">
<!-- Обязательное заметное предупреждение для оператора -->
<div class="security-warning-card" role="alert">
<div class="warning-badge-label">
<span class="warning-icon">⚠️</span>
<strong>ОБЯЗАТЕЛЬНО К ПРОВЕРКЕ ПЕРЕД ПОДТВЕРЖДЕНИЕМ УСТАНОВКИ</strong>
</div>
<p class="warning-text">
При выполнении установщика на сервере обязательно сверьте отпечаток CA, выведенный скриптом, с отпечатком ниже. Подтверждение без сверки обесценивает криптографическую проверку подписи!
</p>
</div>
<div class="result-field-group">
<div class="result-field-header">
<label for="ca-fingerprint-display">Отпечаток CA (SHA-256):</label>
<button id="copy-fingerprint-btn" class="btn btn-sm btn-outline" type="button" title="Скопировать отпечаток CA">
Копировать отпечаток
</button>
</div>
<div class="code-box fingerprint-box" id="ca-fingerprint-display" tabindex="0"></div>
</div>
<div class="result-field-group">
<div class="result-field-header">
<label for="enrollment-code-display">Код регистрации (SMMNODE2):</label>
<button id="copy-code-btn" class="btn btn-sm btn-primary" type="button" title="Скопировать код регистрации">
Скопировать код
</button>
</div>
<textarea
id="enrollment-code-display"
class="code-box enrollment-code-box"
readonly
rows="4"
spellcheck="false"></textarea>
</div>
<div class="result-meta-row">
<div class="expiration-info">
<span class="timer-icon">⏱️</span>
<span>Срок действия: <strong>10 минут</strong></span>
<span id="countdown-timer" class="countdown-badge">10:00</span>
</div>
<button type="button" id="result-done-btn" class="btn btn-secondary">Готово</button>
</div>
</div>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,652 @@
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--bg-card: #1e293b;
--bg-input: #0f172a;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-primary: #2563eb;
--accent-primary-hover: #1d4ed8;
--accent-primary-active: #1e40af;
--border-color: #334155;
--border-subtle: #1e293b;
--success-bg: rgba(16, 185, 129, 0.15);
--success-text: #34d399;
--success-border: #059669;
--warning-bg: rgba(245, 158, 11, 0.15);
--warning-text: #fbbf24;
--warning-border: #d97706;
--danger-bg: rgba(239, 68, 68, 0.15);
--danger-text: #f87171;
--danger-border: #dc2626;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 1rem;
}
.header-brand {
display: flex;
align-items: center;
gap: 0.875rem;
}
.brand-badge {
background-color: var(--accent-primary);
color: #fff;
font-weight: 700;
font-size: 1rem;
padding: 0.35rem 0.65rem;
border-radius: 6px;
letter-spacing: 0.05em;
}
.brand-titles h1 {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.brand-subtitle {
font-size: 0.8rem;
color: var(--text-secondary);
display: block;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--bg-primary);
padding: 0.4rem 0.85rem;
border-radius: 6px;
border: 1px solid var(--border-color);
font-size: 0.825rem;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #10b981;
display: inline-block;
box-shadow: 0 0 6px #10b981;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
user-select: none;
}
.btn:active {
transform: scale(0.98);
}
.btn:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.btn-primary {
background-color: var(--accent-primary);
color: #ffffff;
}
.btn-primary:hover {
background-color: var(--accent-primary-hover);
}
.btn-secondary {
background-color: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-color);
}
.btn-secondary:hover {
background-color: #475569;
}
.btn-outline {
background-color: transparent;
color: var(--text-primary);
border-color: var(--border-color);
}
.btn-outline:hover {
background-color: var(--bg-tertiary);
}
.btn-sm {
font-size: 0.775rem;
padding: 0.3rem 0.65rem;
}
.btn-icon {
font-size: 1rem;
line-height: 1;
}
/* Main content */
.app-main {
flex: 1;
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
.dashboard-section {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
}
.section-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.section-title-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.section-title-group h2 {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
}
.counter-badge {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
}
.section-hint {
font-size: 0.825rem;
color: var(--text-muted);
}
/* Tables */
.table-container {
overflow-x: auto;
min-height: 140px;
}
.data-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.875rem;
}
.data-table th {
background-color: rgba(15, 23, 42, 0.6);
color: var(--text-secondary);
font-weight: 600;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-color);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.data-table td {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
vertical-align: middle;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover {
background-color: rgba(255, 255, 255, 0.02);
}
.node-name-cell {
font-family: var(--font-mono);
font-weight: 600;
color: #60a5fa;
}
.link-id-cell {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
}
.loading-row td {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: 2.5rem;
}
/* Status Badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.775rem;
font-weight: 600;
padding: 0.25rem 0.65rem;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.status-badge.active,
.status-badge.online,
.status-badge.connected {
background-color: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success-border);
}
.status-badge.connecting,
.status-badge.pending {
background-color: var(--warning-bg);
color: var(--warning-text);
border: 1px solid var(--warning-border);
}
.status-badge.inactive,
.status-badge.offline,
.status-badge.disabled,
.status-badge.failed {
background-color: var(--danger-bg);
color: var(--danger-text);
border: 1px solid var(--danger-border);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3.5rem 2rem;
text-align: center;
}
.empty-state-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.empty-state-text {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.empty-state-subtext {
font-size: 0.85rem;
color: var(--text-muted);
max-width: 450px;
}
/* Alerts */
.alert {
padding: 1rem 1.25rem;
border-radius: 6px;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.alert-error {
background-color: var(--danger-bg);
color: var(--danger-text);
border: 1px solid var(--danger-border);
}
.alert-success {
background-color: var(--success-bg);
color: var(--success-text);
border: 1px solid var(--success-border);
}
/* Modals */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-window {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
width: 100%;
max-width: 640px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
overflow: hidden;
animation: modalEnter 0.15s ease-out;
}
@keyframes modalEnter {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.modal-header h3 {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
}
.modal-close {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
border-radius: 4px;
}
.modal-close:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
.modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Forms */
.form-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-group label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.form-control {
font-family: var(--font-mono);
font-size: 0.925rem;
padding: 0.65rem 0.85rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.form-control:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
}
.form-hint {
font-size: 0.775rem;
color: var(--text-muted);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1rem;
}
/* Enrollment Code Result */
.enrollment-result-container {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.security-warning-card {
background-color: #451a03;
border: 2px solid #f59e0b;
border-radius: 8px;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.warning-badge-label {
display: flex;
align-items: center;
gap: 0.5rem;
color: #fbbf24;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.025em;
}
.warning-text {
font-size: 0.825rem;
color: #fef3c7;
line-height: 1.45;
}
.result-field-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.result-field-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.result-field-header label {
font-size: 0.825rem;
font-weight: 600;
color: var(--text-secondary);
}
.code-box {
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.75rem;
font-family: var(--font-mono);
font-size: 0.825rem;
color: #e2e8f0;
word-break: break-all;
width: 100%;
}
.fingerprint-box {
color: #38bdf8;
font-weight: 600;
}
.enrollment-code-box {
resize: vertical;
min-height: 90px;
line-height: 1.4;
outline: none;
}
.enrollment-code-box:focus {
border-color: var(--accent-primary);
}
.result-meta-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.expiration-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.825rem;
color: var(--text-secondary);
}
.countdown-badge {
background-color: var(--warning-bg);
color: var(--warning-text);
border: 1px solid var(--warning-border);
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-family: var(--font-mono);
font-weight: 700;
}
/* Footer */
.app-footer {
padding: 1.25rem 2rem;
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--text-muted);
flex-wrap: wrap;
gap: 0.5rem;
}
.footer-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.footer-separator {
color: var(--border-color);
}
/* Utilities */
.hidden {
display: none !important;
}
/* Responsive */
@media (max-width: 768px) {
.app-header {
padding: 1rem;
}
.app-main {
padding: 1rem;
}
.app-footer {
padding: 1rem;
}
.modal-window {
margin: 0.5rem;
}
}

View file

@ -0,0 +1,355 @@
extern alias controlapp;
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Xunit;
namespace ServerMonitorManager.Control.Tests;
public sealed class WebConsoleTests : IAsyncDisposable
{
private readonly WebConsoleTestFactory _factory = new();
[Theory]
[InlineData("/")]
[InlineData("/index.html")]
[InlineData("/style.css")]
[InlineData("/app.js")]
[InlineData("/console")]
public async Task AnonymousAccessToWebConsoleRoutesIsRejectedWithUnauthorized(string path)
{
using var client = _factory.CreateClient();
var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Theory]
[InlineData("/")]
[InlineData("/index.html")]
[InlineData("/style.css")]
[InlineData("/app.js")]
public async Task AgentRoleIsForbiddenFromWebConsole(string path)
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Identity", "agent-node-01");
client.DefaultRequestHeaders.Add("X-Test-Role", "Agent");
var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Theory]
[InlineData("/")]
[InlineData("/index.html")]
[InlineData("/style.css")]
[InlineData("/app.js")]
public async Task AutomationRoleIsForbiddenFromWebConsole(string path)
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Identity", "automation-worker-01");
client.DefaultRequestHeaders.Add("X-Test-Role", "Automation");
var response = await client.GetAsync(path, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task OperatorCanAccessWebConsoleHtmlAndAssets()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
var indexResponse = await client.GetAsync("/", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode);
Assert.Contains("text/html", indexResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
var cssResponse = await client.GetAsync("/style.css", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode);
Assert.Contains("text/css", cssResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
var jsResponse = await client.GetAsync("/app.js", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, jsResponse.StatusCode);
Assert.Contains("javascript", jsResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task WebConsoleHtmlContainsRequiredUiElementsAndWarning()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
var response = await client.GetAsync("/index.html", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
// Header & Actions
Assert.Contains("id=\"add-node-btn\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"refresh-btn\"", html, StringComparison.Ordinal);
// Tables & Counts
Assert.Contains("id=\"nodes-table\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"links-table\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"nodes-count\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"links-count\"", html, StringComparison.Ordinal);
// Modal & Form
Assert.Contains("id=\"add-node-modal\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"node-id-input\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"generate-code-btn\"", html, StringComparison.Ordinal);
// Result displays
Assert.Contains("id=\"ca-fingerprint-display\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"enrollment-code-display\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"copy-code-btn\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"copy-fingerprint-btn\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"countdown-timer\"", html, StringComparison.Ordinal);
// Security verification requirement for CA fingerprint
Assert.Contains("ОБЯЗАТЕЛЬНО К ПРОВЕРКЕ", html, StringComparison.Ordinal);
Assert.Contains("отпечаток", html, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task EmbeddedAssetsAreServedWhenDiskWebRootIsMissing()
{
await using var embeddedFactory = new WebConsoleEmbeddedOnlyTestFactory();
using var client = embeddedFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Identity", "operator-admin");
client.DefaultRequestHeaders.Add("X-Test-Role", "Operator");
// 1. Root /
var rootResponse = await client.GetAsync("/", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, rootResponse.StatusCode);
Assert.Contains("text/html", rootResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
var rootHtml = await rootResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(rootHtml);
Assert.Contains("id=\"nodes-table\"", rootHtml, StringComparison.Ordinal);
Assert.Contains("id=\"add-node-btn\"", rootHtml, StringComparison.Ordinal);
// 2. /index.html
var indexResponse = await client.GetAsync("/index.html", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode);
Assert.Contains("text/html", indexResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
var indexHtml = await indexResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(indexHtml);
Assert.Contains("id=\"links-table\"", indexHtml, StringComparison.Ordinal);
// 3. /style.css
var cssResponse = await client.GetAsync("/style.css", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode);
Assert.Contains("text/css", cssResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
var cssContent = await cssResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(cssContent);
Assert.Contains(".app-container", cssContent, StringComparison.Ordinal);
// 4. /app.js
var jsResponse = await client.GetAsync("/app.js", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, jsResponse.StatusCode);
Assert.Contains("javascript", jsResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
var jsContent = await jsResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(jsContent);
Assert.Contains("loadDashboardData", jsContent, StringComparison.Ordinal);
// 5. /console alias
var consoleResponse = await client.GetAsync("/console", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, consoleResponse.StatusCode);
Assert.Contains("text/html", consoleResponse.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase);
}
public async ValueTask DisposeAsync() => await _factory.DisposeAsync();
private sealed class WebConsoleEmbeddedOnlyTestFactory : WebApplicationFactory<controlapp::Program>
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"smm-web-console-embedded-{Guid.NewGuid():N}");
public string DatabasePath => Path.Combine(_directory, "control.db");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Directory.CreateDirectory(_directory);
var emptyWebRoot = Path.Combine(_directory, "empty-webroot-no-files");
Directory.CreateDirectory(emptyWebRoot);
builder.UseWebRoot(emptyWebRoot);
var authorityPath = Path.Combine(_directory, "control-ca.pfx");
if (!File.Exists(authorityPath))
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=SMM Web Console Embedded CA", key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx));
}
var publicUrlPath = Path.Combine(_directory, "control-public-url");
File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n");
var meshEnvPath = Path.Combine(_directory, "mesh.env");
File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n");
var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv");
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Control:DatabasePath"] = DatabasePath,
["Control:CertificateAuthorityPath"] = authorityPath,
["Control:BackupDirectory"] = Path.Combine(_directory, "backups"),
["Control:PublicUrlPath"] = publicUrlPath,
["Control:MeshEnvironmentPath"] = meshEnvPath,
["Control:MeshNodesPath"] = meshNodesPath
}));
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Test";
options.DefaultChallengeScheme = "Test";
})
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("Test", _ => { });
});
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
try
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
catch
{
// Ignored in test cleanup
}
}
}
private sealed class WebConsoleTestFactory : WebApplicationFactory<controlapp::Program>
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(), $"smm-web-console-tests-{Guid.NewGuid():N}");
public string DatabasePath => Path.Combine(_directory, "control.db");
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Directory.CreateDirectory(_directory);
var authorityPath = Path.Combine(_directory, "control-ca.pfx");
if (!File.Exists(authorityPath))
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=SMM Web Console Test CA", key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
using var certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
File.WriteAllBytes(authorityPath, certificate.Export(X509ContentType.Pfx));
}
var publicUrlPath = Path.Combine(_directory, "control-public-url");
File.WriteAllText(publicUrlPath, "https://hub.example.com:7443\n");
var meshEnvPath = Path.Combine(_directory, "mesh.env");
File.WriteAllText(meshEnvPath, "HUB_ENDPOINT=hub.example.com:51820\nHUB_PUBLIC_KEY=mQZ/Y4yQpQhX6j0rL8vU2w==\nMESH_NETWORK=10.77.0.0/24\n");
var meshNodesPath = Path.Combine(_directory, "mesh", "nodes.tsv");
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Control:DatabasePath"] = DatabasePath,
["Control:CertificateAuthorityPath"] = authorityPath,
["Control:BackupDirectory"] = Path.Combine(_directory, "backups"),
["Control:PublicUrlPath"] = publicUrlPath,
["Control:MeshEnvironmentPath"] = meshEnvPath,
["Control:MeshNodesPath"] = meshNodesPath
}));
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Test";
options.DefaultChallengeScheme = "Test";
})
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>("Test", _ => { });
});
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
try
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
catch
{
// Ignored in test cleanup
}
}
}
private sealed class TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Test-Role", out var roleValue)
|| string.IsNullOrWhiteSpace(roleValue))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var identityName = Request.Headers.TryGetValue("X-Test-Identity", out var identityValue)
&& !string.IsNullOrWhiteSpace(identityValue)
? identityValue.ToString()
: "test-user";
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, identityName),
new(ClaimTypes.Role, roleValue.ToString())
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name));
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}