Files
ReLibreHardwareMonitor/LibreHardwareMonitor.Windows.WinUI.Tests/Services/RemoteWebServerTests.cs
T
JMR-devandCopilot 594429bb7d Add fully-injected MainWindowViewModel constructor
Introduce an injected constructor taking IHardwareMonitorService, ILogger,
IRemoteWebServer (+ the concrete selection/plot/startup services) and
IStartupTracer, with shared settings initialization. A transitional 2-arg
constructor still builds the legacy graph for MainWindow until it is
DI-resolved; an _ownsServices flag keeps disposal correct across both paths.

Break the VM<->RemoteWebServer construction cycle by moving the root provider
to RemoteWebServer.SetRootProvider (removing it from the constructor), and make
RemoteWebServer.Quit idempotent. Update RemoteWebServer tests for the new ctor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-02 15:09:12 -05:00

376 lines
16 KiB
C#

// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Copyright (C) LibreHardwareMonitor and Contributors.
using System;
using System.Collections.Generic;
using System.Linq;
using LibreHardwareMonitor.Hardware;
using LibreHardwareMonitor.Windows.WinUI.Services;
using LibreHardwareMonitor.Windows.WinUI.ViewModels;
using Moq;
using Xunit;
namespace LibreHardwareMonitor.Windows.WinUI.Tests.Services;
// Characterization tests pinning the behavior the Phase 2 security hardening must preserve:
// request routing (including the no-hijack rule), query parsing, Prometheus settings/units/shape,
// the data.json node shape, and credential-verification semantics (including legacy SHA-256).
public class RemoteWebServerTests
{
// ---- ResolveRoute -------------------------------------------------------
[Theory]
[InlineData("GET", "/data.json", "DataJson")]
[InlineData("GET", "/DATA.JSON", "DataJson")]
[InlineData("GET", "/metrics", "Metrics")]
[InlineData("GET", "/Sensor", "Sensor")]
[InlineData("GET", "/ResetAllMinMax", "ResetAllMinMax")]
[InlineData("POST", "/Sensor", "Post")]
[InlineData("POST", "/anything", "Post")]
public void ResolveRoute_MapsEndpoints(string method, string path, string expectedKind)
{
Assert.Equal(expectedKind, RemoteWebServer.ResolveRoute(method, path, null).Kind.ToString());
}
[Theory]
// A static asset whose name merely starts with an endpoint name must NOT be hijacked by the API handler.
[InlineData("/metrics.html", "Web.metrics.html")]
[InlineData("/data.json.html", "Web.data.json.html")]
[InlineData("/sensor-icons.css", "Web.sensor-icons.css")]
public void ResolveRoute_StaticAssetsAreNotHijackedByEndpoints(string path, string expectedResource)
{
RemoteWebServer.RemoteWebRoute route = RemoteWebServer.ResolveRoute("GET", path, null);
Assert.Equal(RemoteWebServer.RemoteWebRouteKind.Resource, route.Kind);
Assert.Equal(expectedResource, route.ResourcePath);
}
[Fact]
public void ResolveRoute_EmptyPath_ServesIndexHtmlResource()
{
RemoteWebServer.RemoteWebRoute route = RemoteWebServer.ResolveRoute("GET", "/", null);
Assert.Equal(RemoteWebServer.RemoteWebRouteKind.Resource, route.Kind);
Assert.Equal("Web.index.html", route.ResourcePath);
}
[Fact]
public void ResolveRoute_IconPrefix_ServesRawRemainderResource()
{
RemoteWebServer.RemoteWebRoute route = RemoteWebServer.ResolveRoute("GET", "/images_icon/cpu.png", null);
Assert.Equal(RemoteWebServer.RemoteWebRouteKind.Resource, route.Kind);
Assert.Equal("cpu.png", route.ResourcePath);
}
[Fact]
public void ResolveRoute_FallsBackToRawUrlWhenAbsolutePathNull()
{
Assert.Equal(RemoteWebServer.RemoteWebRouteKind.DataJson, RemoteWebServer.ResolveRoute("GET", null, "/data.json").Kind);
}
// ---- ParseQuery ---------------------------------------------------------
[Fact]
public void ParseQuery_ParsesAndUrlDecodes()
{
IDictionary<string, string> query = RemoteWebServer.ParseQuery("?action=Get&id=%2Fcpu%2F0");
Assert.Equal("Get", query["action"]);
Assert.Equal("/cpu/0", query["id"]);
}
[Fact]
public void ParseQuery_KeysAreCaseInsensitive()
{
IDictionary<string, string> query = RemoteWebServer.ParseQuery("?Action=Get");
Assert.True(query.ContainsKey("action"));
}
[Fact]
public void ParseQuery_MissingEquals_YieldsEmptyValue()
{
IDictionary<string, string> query = RemoteWebServer.ParseQuery("?flag");
Assert.Equal("", query["flag"]);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ParseQuery_EmptyOrNull_ReturnsEmpty(string? query)
{
Assert.Empty(RemoteWebServer.ParseQuery(query));
}
// ---- ClampQueryValue ----------------------------------------------------
[Fact]
public void ClampQueryValue_ParsesAndClamps()
{
var query = new Dictionary<string, string> { ["v"] = "5" };
Assert.Equal(5, RemoteWebServer.ClampQueryValue(query, "v", 0, 0, 10));
query["v"] = "99";
Assert.Equal(10, RemoteWebServer.ClampQueryValue(query, "v", 0, 0, 10));
query["v"] = "-5";
Assert.Equal(0, RemoteWebServer.ClampQueryValue(query, "v", 0, 0, 10));
}
[Fact]
public void ClampQueryValue_MissingOrUnparseable_ReturnsFallback()
{
var query = new Dictionary<string, string> { ["v"] = "abc" };
Assert.Equal(3, RemoteWebServer.ClampQueryValue(query, "v", 3, 0, 10));
Assert.Equal(3, RemoteWebServer.ClampQueryValue(query, "missing", 3, 0, 10));
}
// ---- ParsePrometheusSettings (the archive/lastvalue/timestamps interplay) ----
[Fact]
public void ParsePrometheusSettings_Defaults_LastValueOnly()
{
Dictionary<string, int> settings = RemoteWebServer.ParsePrometheusSettings(new Dictionary<string, string>());
Assert.Equal(0, settings["archivelength"]);
Assert.Equal(0, settings["timestamps"]);
Assert.Equal(1, settings["lastvalue"]);
}
[Fact]
public void ParsePrometheusSettings_NoArchiveNoLastValue_ForcesArchiveAndTimestamps()
{
Dictionary<string, int> settings = RemoteWebServer.ParsePrometheusSettings(new Dictionary<string, string> { ["lastvalue"] = "0" });
Assert.Equal(1, settings["archivelength"]);
Assert.Equal(1, settings["timestamps"]);
Assert.Equal(0, settings["lastvalue"]);
}
[Fact]
public void ParsePrometheusSettings_ArchivePositive_ForcesTimestamps()
{
Dictionary<string, int> settings = RemoteWebServer.ParsePrometheusSettings(new Dictionary<string, string> { ["archivelength"] = "3" });
Assert.Equal(3, settings["archivelength"]);
Assert.Equal(1, settings["timestamps"]);
Assert.Equal(1, settings["lastvalue"]);
}
// ---- GetPrometheusUnit --------------------------------------------------
[Theory]
[InlineData(SensorType.Temperature, "_celsius", 1.0)]
[InlineData(SensorType.Clock, "_hertz", 1000000.0)]
[InlineData(SensorType.Data, "_bytes", 1000000000.0)]
[InlineData(SensorType.SmallData, "_bytes", 1048576.0)]
[InlineData(SensorType.Load, "_percent", 1.0)]
[InlineData((SensorType)999, "", 1.0)]
public void GetPrometheusUnit_MapsUnitAndFactor(SensorType sensorType, string expectedSuffix, double expectedFactor)
{
(string suffix, double factor) = RemoteWebServer.GetPrometheusUnit(sensorType);
Assert.Equal(expectedSuffix, suffix);
Assert.Equal(expectedFactor, factor);
}
// ---- GenerateJsonForNode (data.json shape) ------------------------------
[Fact]
public void GenerateJsonForNode_ProducesExpectedShape()
{
ISensor sensor = CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), value: 50f, min: 40f, max: 60f);
IHardware hardware = CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor);
SensorTreeItemViewModel root = SensorTreeItemViewModel.CreateRoot("HOST");
root.Children.Add(SensorTreeItemViewModel.FromHardware(hardware, AppSettings.LoadDefault()));
int nodeIndex = 1;
Dictionary<string, object?> json = RemoteWebServer.GenerateJsonForNode(root, ref nodeIndex);
Assert.Equal(1, json["id"]);
Assert.Equal("HOST", json["Text"]);
var hardwareJson = (Dictionary<string, object?>)((List<object>)json["Children"]!).Single();
Assert.Equal("CPU", hardwareJson["Text"]);
Assert.Equal(hardware.Identifier.ToString(), hardwareJson["HardwareId"]);
var typeGroupJson = (Dictionary<string, object?>)((List<object>)hardwareJson["Children"]!).Single();
var sensorJson = (Dictionary<string, object?>)((List<object>)typeGroupJson["Children"]!).Single();
Assert.Equal(sensor.Identifier.ToString(), sensorJson["SensorId"]);
Assert.Equal("Temperature", sensorJson["Type"]);
Assert.Equal(50f, sensorJson["RawValue"]);
Assert.Equal(40f, sensorJson["RawMin"]);
Assert.Equal(60f, sensorJson["RawMax"]);
Assert.Equal("images/transparent.png", sensorJson["ImageURL"]);
}
// ---- GeneratePrometheusResponse -----------------------------------------
private static readonly Dictionary<string, int> LastValueSettings = new()
{
["archivelength"] = 0,
["timestamps"] = 0,
["lastvalue"] = 1
};
[Fact]
public void GeneratePrometheusResponse_EmitsTypeLineAndMetric()
{
ISensor sensor = CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), value: 50f);
sensor = WithValues(sensor, new SensorValue(50f, DateTime.UtcNow));
SensorTreeItemViewModel root = BuildRoot("HOST", CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor));
string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings);
Assert.Contains("# TYPE lhm_cpu_temperature_celsius gauge", output);
Assert.Contains("lhm_cpu_temperature_celsius {", output);
Assert.Contains("\"host\"=\"HOST\"", output);
Assert.Contains(" 50", output);
}
[Fact]
public void GeneratePrometheusResponse_NaNValue_IsSkippedWithHelpLine()
{
ISensor sensor = CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), value: float.NaN);
sensor = WithValues(sensor, new SensorValue(float.NaN, DateTime.UtcNow));
SensorTreeItemViewModel root = BuildRoot("HOST", CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor));
string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings);
Assert.Contains("has an invalid value and was skipped", output);
}
[Fact]
public void GeneratePrometheusResponse_SameTagEmittedOnce()
{
ISensor sensor1 = WithValues(CreateSensor("Core 0", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), 50f), new SensorValue(50f, DateTime.UtcNow));
ISensor sensor2 = WithValues(CreateSensor("Core 1", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "1"), 60f), new SensorValue(60f, DateTime.UtcNow));
IHardware hardware = CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor1, sensor2);
SensorTreeItemViewModel root = BuildRoot("HOST", hardware);
string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings);
int typeLineCount = output.Split('\n').Count(line => line.StartsWith("# TYPE lhm_cpu_temperature_celsius"));
Assert.Equal(1, typeLineCount);
}
[Fact]
public void GeneratePrometheusResponse_NullRoot_ReturnsEmpty()
{
Assert.Equal("", RemoteWebServer.GeneratePrometheusResponse(null, LastValueSettings));
}
[Fact]
public void GeneratePrometheusResponse_EscapesLabelValues()
{
ISensor sensor = WithValues(CreateSensor("Core \"0\"", SensorType.Temperature, new Identifier("cpu", "0", "temperature", "0"), 50f), new SensorValue(50f, DateTime.UtcNow));
SensorTreeItemViewModel root = BuildRoot("HOST", CreateHardware("CPU", HardwareType.Cpu, new Identifier("cpu", "0"), sensor));
string output = RemoteWebServer.GeneratePrometheusResponse(root, LastValueSettings);
// The double quote in the sensor name must be backslash-escaped so it can't break or inject labels.
Assert.Contains("Core \\\"0\\\"", output);
}
// ---- ComputeSHA256 (legacy hashing; Phase 2 must keep verifying these) ----
[Fact]
public void ComputeSHA256_KnownVector()
{
Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", RemoteWebServer.ComputeSHA256("abc"));
}
// ---- VerifyCredentials --------------------------------------------------
[Fact]
public void VerifyCredentials_AuthDisabled_AlwaysTrue()
{
using RemoteWebServer server = CreateServer(authEnabled: false, "admin", "secret");
Assert.True(server.VerifyCredentials("anything", "anything"));
Assert.True(server.VerifyCredentials(null, null));
}
[Fact]
public void VerifyCredentials_CorrectCredentials_True()
{
using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret");
Assert.True(server.VerifyCredentials("admin", "secret"));
}
[Theory]
[InlineData("admin", "wrong")]
[InlineData("root", "secret")]
[InlineData(null, "secret")]
[InlineData("admin", null)]
public void VerifyCredentials_BadCredentials_False(string? userName, string? password)
{
using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret");
Assert.False(server.VerifyCredentials(userName, password));
}
[Fact]
public void SetPassword_ProducesPbkdf2HashThatVerifies()
{
using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret");
server.SetPassword("newpass");
Assert.StartsWith("pbkdf2$", server.PasswordHash);
Assert.True(server.VerifyCredentials("admin", "newpass"));
Assert.False(server.VerifyCredentials("admin", "secret"));
}
[Fact]
public void VerifyCredentials_LegacyHash_UpgradesToPbkdf2OnSuccess()
{
// CreateServer seeds the stored hash with the legacy unsalted SHA-256 of the password.
using RemoteWebServer server = CreateServer(authEnabled: true, "admin", "secret");
Assert.False(server.PasswordHash.StartsWith("pbkdf2$"));
Assert.True(server.VerifyCredentials("admin", "secret")); // verified via the legacy path...
Assert.StartsWith("pbkdf2$", server.PasswordHash); // ...and transparently upgraded
Assert.True(server.VerifyCredentials("admin", "secret")); // still verifies via the upgraded hash
}
// ---- helpers ------------------------------------------------------------
private static RemoteWebServer CreateServer(bool authEnabled, string userName, string password)
{
return new RemoteWebServer(Mock.Of<IComputer>(), new object(), "localhost", 8085, authEnabled, userName, RemoteWebServer.ComputeSHA256(password));
}
private static ISensor CreateSensor(string name, SensorType type, Identifier identifier, float? value = null, float? min = null, float? max = null)
{
var mock = new Mock<ISensor>();
mock.Setup(s => s.Name).Returns(name);
mock.Setup(s => s.SensorType).Returns(type);
mock.Setup(s => s.Identifier).Returns(identifier);
mock.Setup(s => s.Index).Returns(0);
mock.Setup(s => s.Value).Returns(value);
mock.Setup(s => s.Min).Returns(min);
mock.Setup(s => s.Max).Returns(max);
mock.Setup(s => s.Values).Returns(Array.Empty<SensorValue>());
return mock.Object;
}
private static ISensor WithValues(ISensor sensor, params SensorValue[] values)
{
Mock.Get(sensor).Setup(s => s.Values).Returns(values);
return sensor;
}
private static IHardware CreateHardware(string name, HardwareType type, Identifier identifier, params ISensor[] sensors)
{
var mock = new Mock<IHardware>();
mock.Setup(h => h.Name).Returns(name);
mock.Setup(h => h.HardwareType).Returns(type);
mock.Setup(h => h.Identifier).Returns(identifier);
mock.Setup(h => h.Sensors).Returns(sensors);
mock.Setup(h => h.SubHardware).Returns(Array.Empty<IHardware>());
return mock.Object;
}
private static SensorTreeItemViewModel BuildRoot(string host, IHardware hardware)
{
SensorTreeItemViewModel root = SensorTreeItemViewModel.CreateRoot(host);
root.Children.Add(SensorTreeItemViewModel.FromHardware(hardware, AppSettings.LoadDefault()));
return root;
}
}