server-monitor-manager/tests/ServerMonitorManager.Control.Tests/WebConsoleTests.cs

398 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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=\"create-link-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=\"events-table\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"nodes-count\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"links-count\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"events-count\"", html, StringComparison.Ordinal);
// Add Node 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);
// Create Link Modal & Form
Assert.Contains("id=\"create-link-modal\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"create-link-form\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-source-node\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-target-node\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-protocol\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-port\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-ttl\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-reason\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-direction-summary\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-modal-submit-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 WebConsoleProvidesLinkManagementAndEventsJournal()
{
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("/", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
// Desired & Actual state separation in table headers
Assert.Contains("Желаемое", html, StringComparison.Ordinal);
Assert.Contains("Фактическое", html, StringComparison.Ordinal);
Assert.Contains("Причина", html, StringComparison.Ordinal);
Assert.Contains("Срок действия", html, StringComparison.Ordinal);
Assert.Contains("Действия", html, StringComparison.Ordinal);
// Directional confirmation text in modal
Assert.Contains("Подтверждение направления правила доступа", html, StringComparison.Ordinal);
Assert.Contains("id=\"link-direction-summary\"", html, StringComparison.Ordinal);
// Event journal section
Assert.Contains("id=\"events-section\"", html, StringComparison.Ordinal);
Assert.Contains("Журнал событий", html, StringComparison.Ordinal);
}
[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));
}
}
}