- New device kind Ventilation with driver Vallox (WebSocket protocol of the unit's web UI: READ_TABLES/WRITE_DATA, checksum, big-endian table); no migration (enums stored as text) - Read profile incl. remaining minutes, outdoor/supply/extract/exhaust air temperatures, humidity, CO2, fan speed; activate Home/Away/Boost/Fireplace (timed profiles use the duration configured on the unit) - Same SSRF protection as Shelly (shared connection handler), permissions, action log, audit - Admin dialog without generation/channel/credentials for ventilation, device card with values and profile buttons - Tests: unit (protocol vs. reference bytes, driver, loopback WebSocket, SSRF, rules), bUnit - ADR-0025, roadmap phase 19
353 lines
16 KiB
C#
353 lines
16 KiB
C#
using System.Net;
|
|
using FamilyPlanner.Domain;
|
|
using FamilyPlanner.Domain.Common;
|
|
using FamilyPlanner.Domain.Home;
|
|
using FamilyPlanner.Shared.Home;
|
|
|
|
namespace FamilyPlanner.UnitTests.Home;
|
|
|
|
/// <summary>Reine Regeln des Haus-Moduls (ADR-0023): Adressen/SSRF, Befehle, Freigaben, Positionen, Bilder, Validatoren.</summary>
|
|
public sealed class HomeRulesTests
|
|
{
|
|
[Theory]
|
|
[InlineData("192.168.1.50", "http://192.168.1.50")]
|
|
[InlineData(" 192.168.1.50:8080 ", "http://192.168.1.50:8080")]
|
|
[InlineData("http://10.0.0.5/", "http://10.0.0.5")]
|
|
[InlineData("shelly1.local", "http://shelly1.local")]
|
|
[InlineData("SHELLY.lan", "http://shelly.lan")]
|
|
[InlineData("shelly", "http://shelly")]
|
|
[InlineData("localhost:1234", "http://localhost:1234")]
|
|
[InlineData("https://192.168.0.7", "https://192.168.0.7")]
|
|
[InlineData("[fd00::1]", "http://[fd00::1]")]
|
|
[InlineData("100.64.1.2", "http://100.64.1.2")]
|
|
public void Home_network_hosts_are_accepted_and_normalized(string input, string expected)
|
|
{
|
|
var (host, error) = HomeRules.NormalizeHost(input);
|
|
|
|
Assert.Null(error);
|
|
Assert.Equal(expected, host);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData(null)]
|
|
[InlineData("ftp://192.168.1.5")]
|
|
[InlineData("file:///etc/passwd")]
|
|
[InlineData("http://user:pw@192.168.1.5")]
|
|
[InlineData("http://192.168.1.5/admin")]
|
|
[InlineData("http://192.168.1.5/?x=1")]
|
|
[InlineData("http://192.168.1.5/#a")]
|
|
[InlineData("169.254.169.254")]
|
|
[InlineData("http://[fe80::1]")]
|
|
[InlineData("0.0.0.0")]
|
|
[InlineData("8.8.8.8")]
|
|
[InlineData("example.com")]
|
|
[InlineData("http://172.32.0.1")]
|
|
[InlineData("http://[2001:db8::1]")]
|
|
[InlineData("224.0.0.1")]
|
|
public void Foreign_blocked_or_malformed_hosts_are_rejected_with_a_message(string? input)
|
|
{
|
|
var (host, error) = HomeRules.NormalizeHost(input);
|
|
|
|
Assert.Null(host);
|
|
Assert.False(string.IsNullOrWhiteSpace(error));
|
|
}
|
|
|
|
[Fact]
|
|
public void Overlong_host_is_rejected()
|
|
{
|
|
Assert.NotNull(HomeRules.NormalizeHost(new string('a', 300)).Error);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("127.0.0.1", true)]
|
|
[InlineData("::1", true)]
|
|
[InlineData("10.1.2.3", true)]
|
|
[InlineData("172.16.0.1", true)]
|
|
[InlineData("172.31.255.255", true)]
|
|
[InlineData("192.168.178.20", true)]
|
|
[InlineData("fd12:3456::1", true)]
|
|
[InlineData("::ffff:192.168.1.5", true)]
|
|
[InlineData("169.254.169.254", false)]
|
|
[InlineData("::ffff:169.254.169.254", false)]
|
|
[InlineData("fe80::1", false)]
|
|
[InlineData("0.0.0.0", false)]
|
|
[InlineData("8.8.8.8", false)]
|
|
[InlineData("172.32.0.1", false)]
|
|
[InlineData("255.255.255.255", false)]
|
|
[InlineData("2001:db8::1", false)]
|
|
public void Resolved_addresses_must_be_private_and_not_link_local(string address, bool allowed)
|
|
{
|
|
Assert.Equal(allowed, NetworkRules.IsAllowedHomeAddress(IPAddress.Parse(address)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HomeDeviceKind.Switch, HomeCommand.TurnOn, true)]
|
|
[InlineData(HomeDeviceKind.Switch, HomeCommand.Toggle, true)]
|
|
[InlineData(HomeDeviceKind.Switch, HomeCommand.Open, false)]
|
|
[InlineData(HomeDeviceKind.Socket, HomeCommand.TurnOff, true)]
|
|
[InlineData(HomeDeviceKind.Light, HomeCommand.TurnOn, true)]
|
|
[InlineData(HomeDeviceKind.Light, HomeCommand.Stop, false)]
|
|
[InlineData(HomeDeviceKind.Cover, HomeCommand.Open, true)]
|
|
[InlineData(HomeDeviceKind.Cover, HomeCommand.Close, true)]
|
|
[InlineData(HomeDeviceKind.Cover, HomeCommand.Stop, true)]
|
|
[InlineData(HomeDeviceKind.Cover, HomeCommand.TurnOn, false)]
|
|
[InlineData(HomeDeviceKind.Cover, HomeCommand.Toggle, false)]
|
|
[InlineData(HomeDeviceKind.Sensor, HomeCommand.TurnOn, false)]
|
|
[InlineData(HomeDeviceKind.Sensor, HomeCommand.Toggle, false)]
|
|
[InlineData(HomeDeviceKind.Sensor, HomeCommand.Open, false)]
|
|
[InlineData(HomeDeviceKind.Sensor, HomeCommand.ProfileHome, false)]
|
|
[InlineData(HomeDeviceKind.Switch, HomeCommand.ProfileBoost, false)]
|
|
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileHome, true)]
|
|
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileAway, true)]
|
|
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileBoost, true)]
|
|
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.ProfileFireplace, true)]
|
|
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.TurnOn, false)]
|
|
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.Toggle, false)]
|
|
[InlineData(HomeDeviceKind.Ventilation, HomeCommand.Open, false)]
|
|
public void Commands_must_fit_the_device_kind(HomeDeviceKind kind, HomeCommand command, bool allowed)
|
|
{
|
|
Assert.Equal(allowed, HomeRules.IsCommandAllowed(kind, command));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HomeDriverType.Shelly, HomeDeviceKind.Switch, true)]
|
|
[InlineData(HomeDriverType.Shelly, HomeDeviceKind.Sensor, true)]
|
|
[InlineData(HomeDriverType.Shelly, HomeDeviceKind.Ventilation, false)]
|
|
[InlineData(HomeDriverType.Vallox, HomeDeviceKind.Ventilation, true)]
|
|
[InlineData(HomeDriverType.Vallox, HomeDeviceKind.Switch, false)]
|
|
[InlineData(HomeDriverType.Vallox, HomeDeviceKind.Sensor, false)]
|
|
public void Driver_must_fit_the_device_kind(HomeDriverType driver, HomeDeviceKind kind, bool valid)
|
|
{
|
|
Assert.Equal(valid, HomeRules.IsValidDriverForKind(driver, kind));
|
|
Assert.Equal(valid, HomeRules.DefaultDriver(kind) == driver);
|
|
}
|
|
|
|
[Fact]
|
|
public void Vallox_uses_protocol_version_one_only()
|
|
{
|
|
Assert.True(HomeRules.IsValidGeneration(HomeDriverType.Vallox, 1));
|
|
Assert.False(HomeRules.IsValidGeneration(HomeDriverType.Vallox, 2));
|
|
Assert.False(HomeRules.IsValidGeneration(HomeDriverType.Vallox, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_selectable_ventilation_profile_has_an_allowed_command_and_a_german_name()
|
|
{
|
|
Assert.Equal(4, HomeRules.VentilationProfiles.Count);
|
|
Assert.All(HomeRules.VentilationProfiles, p =>
|
|
{
|
|
Assert.True(HomeRules.IsCommandAllowed(HomeDeviceKind.Ventilation, p.Command));
|
|
Assert.NotEqual(p.Profile.ToString(), HomeRules.DisplayName(p.Profile));
|
|
Assert.NotEqual(p.Command.ToString(), HomeRules.DisplayName(p.Command));
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void Save_validator_requires_the_matching_driver()
|
|
{
|
|
var shellyVentilation = new SaveHomeDeviceRequest("Lüftung", HomeDeviceKind.Ventilation, null, null, HomeDriverType.Shelly, 2, "192.168.1.60", 0, null, null, false);
|
|
var vallox = shellyVentilation with { Driver = HomeDriverType.Vallox, Generation = 1 };
|
|
var valloxGen2 = vallox with { Generation = 2 };
|
|
|
|
Assert.Contains(new SaveHomeDeviceRequestValidator().Validate(shellyVentilation).Errors, e => e.PropertyName == nameof(SaveHomeDeviceRequest.Driver));
|
|
Assert.True(new SaveHomeDeviceRequestValidator().Validate(vallox).IsValid);
|
|
Assert.Contains(new SaveHomeDeviceRequestValidator().Validate(valloxGen2).Errors, e => e.ErrorMessage.Contains("Vallox", StringComparison.Ordinal));
|
|
Assert.False(new TestHomeDeviceRequestValidator().Validate(new TestHomeDeviceRequest(null, HomeDeviceKind.Switch, HomeDriverType.Vallox, 1, "192.168.1.60", 0, null, null)).IsValid);
|
|
}
|
|
|
|
private static readonly Guid Me = Guid.NewGuid();
|
|
|
|
[Fact]
|
|
public void Admin_always_controls_without_any_grant()
|
|
{
|
|
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Admin], []));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Roles.Family)]
|
|
[InlineData(Roles.Child)]
|
|
[InlineData(Roles.Guest)]
|
|
public void Without_a_grant_nobody_but_admin_has_access(string role)
|
|
{
|
|
Assert.Equal(HomeAccessLevel.None, HomeRules.ResolveAccess(Me, [role], []));
|
|
}
|
|
|
|
[Fact]
|
|
public void Role_grant_applies_to_that_role_only()
|
|
{
|
|
var grants = new[] { new HomeGrant(Roles.Family, null, HomeAccessLevel.Control), new HomeGrant(Roles.Guest, null, HomeAccessLevel.View) };
|
|
|
|
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Family], grants));
|
|
Assert.Equal(HomeAccessLevel.View, HomeRules.ResolveAccess(Me, [Roles.Guest], grants));
|
|
Assert.Equal(HomeAccessLevel.None, HomeRules.ResolveAccess(Me, [Roles.Child], grants));
|
|
}
|
|
|
|
[Fact]
|
|
public void Person_grant_applies_to_that_person_only()
|
|
{
|
|
var grants = new[] { new HomeGrant(null, Me, HomeAccessLevel.Control) };
|
|
|
|
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Child], grants));
|
|
Assert.Equal(HomeAccessLevel.None, HomeRules.ResolveAccess(Guid.NewGuid(), [Roles.Child], grants));
|
|
}
|
|
|
|
[Fact]
|
|
public void Highest_level_of_role_and_person_grants_wins()
|
|
{
|
|
var grants = new[] { new HomeGrant(Roles.Child, null, HomeAccessLevel.View), new HomeGrant(null, Me, HomeAccessLevel.Control) };
|
|
|
|
Assert.Equal(HomeAccessLevel.Control, HomeRules.ResolveAccess(Me, [Roles.Child], grants));
|
|
Assert.Equal(HomeAccessLevel.View, HomeRules.ResolveAccess(Guid.NewGuid(), [Roles.Child], grants));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Roles.Family, null, HomeAccessLevel.View, true)]
|
|
[InlineData(Roles.Child, null, HomeAccessLevel.Control, true)]
|
|
[InlineData(Roles.Guest, null, HomeAccessLevel.View, true)]
|
|
[InlineData(Roles.Admin, null, HomeAccessLevel.Control, false)]
|
|
[InlineData("Hacker", null, HomeAccessLevel.View, false)]
|
|
[InlineData(Roles.Child, null, HomeAccessLevel.None, false)]
|
|
[InlineData(null, null, HomeAccessLevel.View, false)]
|
|
public void Grant_validation_checks_role_and_level(string? role, Guid? user, HomeAccessLevel level, bool valid)
|
|
{
|
|
Assert.Equal(valid, HomeRules.ValidateGrant(role, user, level) is null);
|
|
}
|
|
|
|
[Fact]
|
|
public void Grant_is_for_a_role_or_a_person_never_both()
|
|
{
|
|
Assert.Null(HomeRules.ValidateGrant(null, Guid.NewGuid(), HomeAccessLevel.View));
|
|
Assert.NotNull(HomeRules.ValidateGrant(Roles.Child, Guid.NewGuid(), HomeAccessLevel.View));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0.0, true)]
|
|
[InlineData(1.0, true)]
|
|
[InlineData(0.5, true)]
|
|
[InlineData(-0.001, false)]
|
|
[InlineData(1.001, false)]
|
|
[InlineData(double.NaN, false)]
|
|
[InlineData(double.PositiveInfinity, false)]
|
|
public void Placement_coordinates_are_relative(double value, bool valid)
|
|
{
|
|
Assert.Equal(valid, HomeRules.IsValidCoordinate(value));
|
|
}
|
|
|
|
[Fact]
|
|
public void Coordinates_are_rounded_and_clamped()
|
|
{
|
|
Assert.Equal(0.1235, HomeRules.RoundCoordinate(0.123456));
|
|
Assert.Equal(1, HomeRules.RoundCoordinate(1.5));
|
|
Assert.Equal(0, HomeRules.RoundCoordinate(-3));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1, true)]
|
|
[InlineData(2, true)]
|
|
[InlineData(0, false)]
|
|
[InlineData(3, false)]
|
|
public void Shelly_generation_is_one_or_two(int generation, bool valid)
|
|
{
|
|
Assert.Equal(valid, HomeRules.IsValidGeneration(HomeDriverType.Shelly, generation));
|
|
}
|
|
|
|
[Fact]
|
|
public void Plan_image_type_comes_from_the_signature()
|
|
{
|
|
byte[] png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
|
|
byte[] jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0];
|
|
var webp = "RIFF\0\0\0\0WEBPVP8 "u8.ToArray();
|
|
|
|
Assert.Equal("image/png", HomeRules.ValidatePlanImage(png).ContentType);
|
|
Assert.Equal("image/jpeg", HomeRules.ValidatePlanImage(jpeg).ContentType);
|
|
Assert.Equal("image/webp", HomeRules.ValidatePlanImage(webp).ContentType);
|
|
Assert.Null(HomeRules.ValidatePlanImage("<svg xmlns='http://www.w3.org/2000/svg'/>"u8).ContentType);
|
|
Assert.Null(HomeRules.ValidatePlanImage("GIF89a......"u8).ContentType);
|
|
Assert.NotNull(HomeRules.ValidatePlanImage([]).Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void Plan_image_size_is_limited()
|
|
{
|
|
var big = new byte[HomeRules.MaxPlanImageBytes + 1];
|
|
new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }.CopyTo(big, 0);
|
|
|
|
Assert.Contains("zu groß", HomeRules.ValidatePlanImage(big).Error);
|
|
}
|
|
|
|
// ---------- Validatoren ----------
|
|
|
|
private static SaveHomeDeviceRequest Device() =>
|
|
new("Wohnzimmer-Licht", HomeDeviceKind.Switch, "bulb", null, HomeDriverType.Shelly, 2, "192.168.1.50", 0, null, null, false);
|
|
|
|
[Fact]
|
|
public void Valid_device_request_passes()
|
|
{
|
|
Assert.True(new SaveHomeDeviceRequestValidator().Validate(Device()).IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void Device_request_is_validated_field_by_field()
|
|
{
|
|
var validator = new SaveHomeDeviceRequestValidator();
|
|
|
|
Assert.False(validator.Validate(Device() with { Name = " " }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Name = new string('x', 81) }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Host = "8.8.8.8" }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Host = "169.254.169.254" }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Generation = 3 }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Channel = -1 }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Channel = 16 }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Kind = (HomeDeviceKind)99 }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Icon = "rocket" }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Password = new string('p', 101) }).IsValid);
|
|
Assert.False(validator.Validate(Device() with { Password = "x", ClearPassword = true }).IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void Access_request_rejects_admin_role_duplicates_and_mixed_grants()
|
|
{
|
|
var validator = new SetHomeAccessRequestValidator();
|
|
var person = Guid.NewGuid();
|
|
|
|
Assert.True(validator.Validate(new SetHomeAccessRequest([])).IsValid);
|
|
Assert.True(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, null, HomeAccessLevel.View), new(null, person, HomeAccessLevel.Control)])).IsValid);
|
|
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Admin, null, HomeAccessLevel.View)])).IsValid);
|
|
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, null, HomeAccessLevel.View), new(Roles.Child, null, HomeAccessLevel.Control)])).IsValid);
|
|
Assert.False(validator.Validate(new SetHomeAccessRequest([new(null, person, HomeAccessLevel.View), new(null, person, HomeAccessLevel.Control)])).IsValid);
|
|
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, person, HomeAccessLevel.View)])).IsValid);
|
|
Assert.False(validator.Validate(new SetHomeAccessRequest([new(Roles.Child, null, HomeAccessLevel.None)])).IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void Placements_request_keeps_pins_on_the_plan_and_unique()
|
|
{
|
|
var validator = new SaveHomePlacementsRequestValidator();
|
|
var device = Guid.NewGuid();
|
|
|
|
Assert.True(validator.Validate(new SaveHomePlacementsRequest([new(device, 0.2, 0.8)])).IsValid);
|
|
Assert.False(validator.Validate(new SaveHomePlacementsRequest([new(device, 1.2, 0.8)])).IsValid);
|
|
Assert.False(validator.Validate(new SaveHomePlacementsRequest([new(device, 0.2, -0.1)])).IsValid);
|
|
Assert.False(validator.Validate(new SaveHomePlacementsRequest([new(device, 0.2, 0.2), new(device, 0.3, 0.3)])).IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void Room_and_plan_names_are_required()
|
|
{
|
|
Assert.False(new SaveHomeRoomRequestValidator().Validate(new SaveHomeRoomRequest(" ")).IsValid);
|
|
Assert.False(new SaveHomeRoomRequestValidator().Validate(new SaveHomeRoomRequest(new string('r', 61))).IsValid);
|
|
Assert.True(new SaveHomeRoomRequestValidator().Validate(new SaveHomeRoomRequest("Küche")).IsValid);
|
|
Assert.False(new SaveHomePlanRequestValidator().Validate(new SaveHomePlanRequest("")).IsValid);
|
|
Assert.True(new SaveHomePlanRequestValidator().Validate(new SaveHomePlanRequest("Erdgeschoss")).IsValid);
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_command_is_rejected()
|
|
{
|
|
Assert.False(new HomeCommandRequestValidator().Validate(new HomeCommandRequest((HomeCommand)42)).IsValid);
|
|
Assert.True(new HomeCommandRequestValidator().Validate(new HomeCommandRequest(HomeCommand.Toggle)).IsValid);
|
|
}
|
|
}
|