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 { 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 { ["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(); services.AddAuthentication(options => { options.DefaultAuthenticateScheme = "Test"; options.DefaultChallengeScheme = "Test"; }) .AddScheme("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 { 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 { ["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(); services.AddAuthentication(options => { options.DefaultAuthenticateScheme = "Test"; options.DefaultChallengeScheme = "Test"; }) .AddScheme("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 options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { protected override Task 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 { 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)); } } }