Files
Rbm.FamStack/tests/FamilyPlanner.Web.Tests/House/HouseAdminTests.cs
T
openclaw 70f1890641 feat: Phase 19 Haus - Vallox MV ventilation unit (profile, temperatures, profile switching)
- 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
2026-10-16 20:04:14 +02:00

459 lines
20 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.
using FamilyPlanner.Domain;
using FamilyPlanner.Domain.Home;
using FamilyPlanner.Shared;
using FamilyPlanner.Shared.Home;
using FamilyPlanner.Web.Client.Components.House;
using FamilyPlanner.Web.Client.Services;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace FamilyPlanner.Web.Tests.House;
/// <summary>Hausverwaltung: Geräteliste und -dialog (lokale Prüfung, Test, Passwort), Freigaben, Räume, Protokoll, Pin-Editor.</summary>
public sealed class HouseAdminTests : BunitContext
{
private readonly FakeHomeApi _api = new();
public HouseAdminTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddMudServices();
Services.AddSingleton<IHomeApi>(_api);
}
private IRenderedComponent<HouseHost> Open<T>(Action<ComponentParameterCollectionBuilder<T>>? parameters = null)
where T : Microsoft.AspNetCore.Components.IComponent =>
Render<HouseHost>(p => p.AddChildContent<T>(parameters));
// ---------- Geräteliste ----------
[Fact]
public void Empty_device_list_explains_the_next_step()
{
var cut = Open<HouseDevicesAdmin>();
Assert.Contains("Noch keine Geräte", cut.Find(".fp-device-empty").TextContent);
}
[Fact]
public void Device_list_shows_connection_shares_and_never_the_password()
{
var room = new HomeAdminRoomDto(Guid.NewGuid(), "Flur", 0, 1);
_api.Rooms.Add(room);
_api.Devices.Add(FakeHomeApi.AdminDevice("Flurlicht", room: room.Id, hasPassword: true, grants:
[
new(Roles.Family, null, HomeAccessLevel.Control),
new(Roles.Child, null, HomeAccessLevel.View),
new(null, Guid.NewGuid(), HomeAccessLevel.Control),
]));
_api.Devices.Add(FakeHomeApi.AdminDevice("Privat", HomeDeviceKind.Cover));
var cut = Open<HouseDevicesAdmin>();
var rows = cut.FindAll(".fp-device-table tbody tr");
Assert.Equal(2, rows.Count);
Assert.Contains("Flur", rows[0].TextContent);
Assert.Contains("http://192.168.1.50", rows[0].TextContent);
Assert.Contains("Gen2 · Kanal 0 · mit Passwort", rows[0].TextContent);
Assert.Contains("Familie: schalten · Kind: ansehen · 1 Person", rows[0].QuerySelector(".fp-device-grants")!.TextContent);
Assert.Contains("Nur Administratoren", rows[1].QuerySelector(".fp-device-grants")!.TextContent);
Assert.Contains("Rollo", rows[1].TextContent);
}
[Fact]
public void Connection_test_of_a_row_shows_model_firmware_and_duration()
{
var device = FakeHomeApi.AdminDevice("Licht");
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-test-row").Click();
cut.WaitForAssertion(() => Assert.Contains("Verbindung erfolgreich", cut.Find(".fp-device-testoutcome").TextContent));
Assert.Contains("SNSW-001X16EU", cut.Find(".fp-device-testoutcome").TextContent);
Assert.Contains("1.4.4", cut.Find(".fp-device-testoutcome").TextContent);
Assert.Equal([device.Id], _api.Tested);
_api.TestResult = new HomeTestResultDto(false, "Das Gerät ist nicht erreichbar.", null, null, 0);
cut.Find(".fp-device-test-row").Click();
cut.WaitForAssertion(() => Assert.Contains("nicht erreichbar", cut.Find(".fp-device-testoutcome").TextContent));
}
[Fact]
public void Delete_asks_for_confirmation_and_removes_the_device()
{
var device = FakeHomeApi.AdminDevice("Alt");
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find("button[aria-label='Alt löschen']").Click();
cut.WaitForElement(".mud-message-box");
Assert.Contains("Gerät löschen?", cut.Markup);
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Equal([device.Id], _api.Deleted));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-device-empty")));
}
// ---------- Gerätedialog ----------
[Fact]
public void Device_dialog_validates_locally_including_foreign_addresses()
{
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#house-device-form").Submit(); // Name und Adresse fehlen
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Namen eingeben", cut.Markup));
Assert.Empty(_api.SavedDevices);
cut.Find("#device-name").Change("Licht");
cut.Find("#device-host").Change("8.8.8.8");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Nur Adressen im Heimnetz sind erlaubt", cut.Markup));
Assert.Empty(_api.SavedDevices);
cut.Find("#device-host").Change("169.254.169.254");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("nicht erlaubt", cut.Markup));
Assert.Empty(_api.SavedDevices);
}
[Fact]
public void Device_dialog_saves_a_valid_request_with_defaults()
{
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-name").Change(" Stehlampe ");
cut.Find("#device-host").Change("192.168.1.77");
cut.Find("#device-user").Change("admin");
cut.Find("#device-password").Change("geheim");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedDevices));
var saved = _api.SavedDevices.Single();
Assert.Equal(("Stehlampe", HomeDeviceKind.Switch, 2, "192.168.1.77", 0, "admin", "geheim", false), (saved.Name, saved.Kind, saved.Generation, saved.Host, saved.Channel, saved.AuthUser, saved.Password, saved.ClearPassword));
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-device-table tbody tr")));
Assert.DoesNotContain("geheim", cut.Markup);
}
[Fact]
public void Server_validation_problems_are_shown_in_the_dialog()
{
_api.SaveProblem = new ApiProblem { Status = 400, Errors = new() { ["RoomId"] = ["Der Raum existiert nicht mehr."] } };
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-name").Change("Licht");
cut.Find("#device-host").Change("192.168.1.5");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Der Raum existiert nicht mehr", cut.Markup));
}
[Fact]
public void Edit_dialog_keeps_the_password_unless_replaced_and_never_shows_it()
{
_api.Devices.Add(FakeHomeApi.AdminDevice("Mit Passwort", hasPassword: true));
var cut = Open<HouseDevicesAdmin>();
cut.Find("button[aria-label='Mit Passwort bearbeiten']").Click();
cut.WaitForElement("#house-device-form");
Assert.Equal(string.Empty, cut.Find("#device-password").GetAttribute("value") ?? string.Empty);
Assert.Contains("leer lassen = unverändert", cut.Markup);
Assert.Equal("admin", cut.Find("#device-user").GetAttribute("value"));
cut.Find("#device-name").Change("Umbenannt");
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedDevices));
var saved = _api.SavedDevices.Single();
Assert.Equal(("Umbenannt", null, false), (saved.Name, saved.Password, saved.ClearPassword));
Assert.NotNull(saved.Version); // Versionsprüfung wird mitgeschickt
Assert.True(_api.Devices[0].HasPassword);
}
[Fact]
public void Ventilation_dialog_hides_shelly_fields_and_saves_with_the_vallox_driver()
{
var device = FakeHomeApi.ValloxDevice("Lüftung");
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
Assert.Contains("Vallox MV", cut.Find(".fp-device-table tbody tr").TextContent);
cut.Find("button[aria-label='Lüftung bearbeiten']").Click();
cut.WaitForElement("#house-device-form");
Assert.Single(cut.FindAll(".fp-device-vallox-hint"));
Assert.Empty(cut.FindAll(".fp-device-generation, #device-channel, #device-user, #device-password"));
cut.Find(".fp-device-test").Click();
cut.WaitForAssertion(() => Assert.Single(_api.DraftTests));
var draft = _api.DraftTests.Single();
Assert.Equal((HomeDriverType.Vallox, 1, 0, null, null), (draft.Driver, draft.Generation, draft.Channel, draft.AuthUser, draft.Password));
cut.Find("#house-device-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedDevices));
var saved = _api.SavedDevices.Single();
Assert.Equal((HomeDeviceKind.Ventilation, HomeDriverType.Vallox, 1, "http://192.168.1.60", 0, null), (saved.Kind, saved.Driver, saved.Generation, saved.Host, saved.Channel, saved.AuthUser));
}
[Fact]
public void Dialog_test_button_sends_the_draft_with_the_device_id_and_shows_the_result()
{
var device = FakeHomeApi.AdminDevice("Gesichert", hasPassword: true);
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find("button[aria-label='Gesichert bearbeiten']").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-channel").Change("1");
cut.Find(".fp-device-test").Click();
cut.WaitForAssertion(() => Assert.Contains("Verbindung erfolgreich", cut.Find(".fp-device-testresult").TextContent));
var draft = _api.DraftTests.Single();
Assert.Equal((device.Id, "http://192.168.1.50", 1, null), (draft.DeviceId, draft.Host, draft.Channel, draft.Password)); // gespeichertes Passwort bleibt auf dem Server
}
[Fact]
public void Dialog_test_with_an_invalid_address_does_not_call_the_server()
{
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-add").Click();
cut.WaitForElement("#house-device-form");
cut.Find("#device-host").Change("example.com");
cut.Find(".fp-device-test").Click();
cut.WaitForAssertion(() => Assert.Contains("Nur Adressen im Heimnetz", cut.Markup));
Assert.Empty(_api.DraftTests);
}
// ---------- Freigaben ----------
[Fact]
public void Access_dialog_shows_existing_shares_and_saves_removals()
{
var anna = FakeHomeApi.User("Anna");
var tom = FakeHomeApi.User("Tom");
_api.Users.AddRange([anna, tom, FakeHomeApi.User("Chefin", Roles.Admin)]);
var device = FakeHomeApi.AdminDevice("Licht", grants: [new(Roles.Family, null, HomeAccessLevel.Control), new(null, anna.Id, HomeAccessLevel.View)]);
_api.Devices.Add(device);
var cut = Open<HouseDevicesAdmin>();
cut.Find(".fp-device-access").Click();
cut.WaitForElement("#house-access-form");
Assert.Equal(3, cut.FindAll(".fp-access-role").Count); // Familie, Kind, Gast – nie Admin
Assert.Single(cut.FindAll(".fp-access-person"));
Assert.Contains("Anna", cut.Find(".fp-access-person").TextContent);
cut.Find("button[aria-label='Freigabe für Anna entfernen']").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-access-person")));
cut.Find("#house-access-form").Submit();
cut.WaitForAssertion(() => Assert.Single(_api.SavedAccess));
var (id, request) = _api.SavedAccess.Single();
Assert.Equal(device.Id, id);
Assert.Equal([new HomeGrantDto(Roles.Family, null, HomeAccessLevel.Control)], request.Grants);
}
// ---------- Räume ----------
[Fact]
public void Rooms_can_be_added_and_deleted_after_confirmation()
{
_api.Rooms.Add(new HomeAdminRoomDto(Guid.NewGuid(), "Küche", 0, 2));
var changes = 0;
var cut = Open<HouseRoomsAdmin>(p => p.Add(c => c.OnChanged, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, () => changes++)));
Assert.Single(cut.FindAll(".fp-room-table tbody tr"));
Assert.Contains("2", cut.Find(".fp-room-table tbody tr").TextContent);
cut.Find(".fp-room-add").Click();
cut.WaitForElement("#house-name-form");
cut.Find("#house-name").Change("Bad");
cut.Find("#house-name-form").Submit();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-room-table tbody tr").Count));
Assert.Equal("Bad", _api.SavedRooms.Single().Name);
Assert.Equal(1, changes);
cut.Find("button[aria-label='Küche löschen']").Click();
cut.WaitForElement(".mud-message-box");
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-room-table tbody tr")));
Assert.Equal(2, changes);
}
[Fact]
public void Room_dialog_requires_a_name()
{
var cut = Open<HouseRoomsAdmin>();
cut.Find(".fp-room-add").Click();
cut.WaitForElement("#house-name-form");
cut.Find("#house-name-form").Submit();
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Namen eingeben", cut.Markup));
Assert.Empty(_api.SavedRooms);
}
// ---------- Protokoll ----------
[Fact]
public void Action_log_shows_who_did_what_and_failures_with_text()
{
var device = Guid.NewGuid();
_api.Log.Add(new HomeActionLogDto(new DateTimeOffset(2026, 10, 12, 8, 0, 0, TimeSpan.Zero), "Mama", device, "Flurlicht", HomeCommand.TurnOn, true, null, 85));
_api.Log.Add(new HomeActionLogDto(new DateTimeOffset(2026, 10, 12, 7, 0, 0, TimeSpan.Zero), "Lena", device, "Rollo", HomeCommand.Close, false, "Das Gerät ist nicht erreichbar.", 3000));
var cut = Open<HouseLogView>();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-house-log tbody tr").Count));
var rows = cut.FindAll(".fp-house-log tbody tr");
Assert.Contains("Mama", rows[0].TextContent);
Assert.Contains("Einschalten", rows[0].TextContent);
Assert.Contains("OK · 85 ms", rows[0].QuerySelector(".fp-log-ok")!.TextContent);
Assert.Contains("10:00", rows[0].TextContent); // Europe/Berlin (Sommerzeit)
Assert.Contains("Fehlgeschlagen: Das Gerät ist nicht erreichbar.", rows[1].QuerySelector(".fp-log-failed")!.TextContent);
}
// ---------- Pin-Editor ----------
private (HomeAdminPlanDto Plan, HomeAdminDeviceDto A, HomeAdminDeviceDto B) PlanSetup()
{
var a = FakeHomeApi.AdminDevice("Licht");
var b = FakeHomeApi.AdminDevice("Steckdose", HomeDeviceKind.Socket);
_api.Devices.AddRange([a, b]);
var plan = new HomeAdminPlanDto(Guid.NewGuid(), "Erdgeschoss", 0, 2, [new HomePinDto(a.Id, 0.5, 0.5)]);
_api.Plans.Add(plan);
return (plan, a, b);
}
[Fact]
public void Pin_editor_lists_all_devices_with_their_placement_state_and_positions_pins()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var devices = cut.FindAll(".fp-pin-device");
Assert.Equal(2, devices.Count);
Assert.Contains("platziert", devices[0].QuerySelector(".fp-pin-device-state")!.TextContent);
Assert.Contains("nicht platziert", devices[1].QuerySelector(".fp-pin-device-state")!.TextContent);
var pin = Assert.Single(cut.FindAll(".fp-pin-edit"));
Assert.Equal("left:50%;top:50%", pin.GetAttribute("style"));
Assert.Equal(a.Id.ToString(), pin.GetAttribute("data-device"));
Assert.Contains("Pfeiltasten", pin.GetAttribute("aria-label"));
Assert.True(cut.Find(".fp-pin-save").HasAttribute("disabled")); // noch nichts geändert
Assert.Contains(JSInterop.Invocations, i => i.Identifier == "fpHome.attach");
}
[Fact]
public void Placing_a_device_by_clicking_the_plan_and_saving_sends_relative_coordinates()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var editor = cut.FindComponent<PlanPinEditor>();
cut.FindAll(".fp-pin-device")[1].Click(); // Steckdose wählen
cut.InvokeAsync(() => editor.Instance.PlanClicked(0.3333333, 0.75)); // Klick aufs Bild (kommt vom Skript)
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-pin-edit").Count));
Assert.Equal("33", cut.Find(".fp-pin-x input").GetAttribute("value"));
Assert.Equal("75", cut.Find(".fp-pin-y input").GetAttribute("value"));
cut.Find(".fp-pin-save").Click();
cut.WaitForAssertion(() => Assert.Single(_api.SavedPlacements));
var (planId, request) = _api.SavedPlacements.Single();
Assert.Equal(plan.Id, planId);
Assert.Equal(2, request.Pins.Count);
var placed = request.Pins.Single(p => p.DeviceId == b.Id);
Assert.Equal((0.3333, 0.75), (placed.X, placed.Y));
Assert.Contains("Positionen gespeichert", cut.Find(".mud-snackbar").TextContent);
}
[Fact]
public void Dragging_and_arrow_keys_move_the_pin_and_stay_inside_the_plan()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var editor = cut.FindComponent<PlanPinEditor>();
cut.InvokeAsync(() => editor.Instance.PinMoved(a.Id.ToString(), 0.2, 0.9));
cut.WaitForAssertion(() => Assert.Equal("left:20%;top:90%", cut.Find(".fp-pin-edit").GetAttribute("style")));
cut.InvokeAsync(() => editor.Instance.PinNudged(a.Id.ToString(), 0.05, 0.05));
cut.WaitForAssertion(() => Assert.Equal("left:25%;top:95%", cut.Find(".fp-pin-edit").GetAttribute("style")));
cut.InvokeAsync(() => editor.Instance.PinNudged(a.Id.ToString(), 0, 0.5));
cut.WaitForAssertion(() => Assert.Equal("left:25%;top:100%", cut.Find(".fp-pin-edit").GetAttribute("style")));
}
[Fact]
public void Removing_a_pin_and_resetting_works_and_clicks_without_selection_are_ignored()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
var editor = cut.FindComponent<PlanPinEditor>();
cut.InvokeAsync(() => editor.Instance.PlanClicked(0.1, 0.1)); // nichts gewählt
Assert.Single(cut.FindAll(".fp-pin-edit"));
cut.FindAll(".fp-pin-device")[0].Click();
cut.Find(".fp-pin-remove").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".fp-pin-edit")));
Assert.False(cut.Find(".fp-pin-save").HasAttribute("disabled"));
cut.Find(".fp-pin-reset").Click();
cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".fp-pin-edit")));
Assert.True(cut.Find(".fp-pin-save").HasAttribute("disabled"));
Assert.Empty(_api.SavedPlacements);
}
[Fact]
public void Unplaced_device_can_be_put_in_the_middle_without_a_pointer()
{
var (plan, a, b) = PlanSetup();
var cut = Open<PlanPinEditor>(p => p.Add(c => c.Plan, plan).Add(c => c.Devices, [a, b]));
cut.FindAll(".fp-pin-device")[1].Click();
cut.Find(".fp-pin-center").Click();
cut.WaitForAssertion(() => Assert.Equal(2, cut.FindAll(".fp-pin-edit").Count));
}
[Fact]
public void Plans_admin_lists_plans_with_preview_and_pin_counts()
{
var (plan, _, _) = PlanSetup();
var cut = Open<HousePlansAdmin>();
var card = cut.Find(".fp-plan-card");
Assert.Contains("Erdgeschoss", card.TextContent);
Assert.Contains("1 Gerät auf dem Plan", card.TextContent);
Assert.Equal($"api/home/plans/{plan.Id}/image?v=2", card.QuerySelector(".fp-plan-thumb")!.GetAttribute("src"));
Assert.Contains("Vorschau Erdgeschoss", card.QuerySelector(".fp-plan-thumb")!.GetAttribute("alt"));
Assert.Empty(cut.FindAll(".fp-pin-editor"));
cut.Find(".fp-plan-pins").Click();
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-pin-editor")));
}
[Fact]
public void Plan_delete_asks_for_confirmation()
{
var (plan, _, _) = PlanSetup();
var cut = Open<HousePlansAdmin>();
cut.FindAll(".fp-plan-card button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForElement(".mud-message-box");
Assert.Contains("Plan löschen?", cut.Markup);
cut.FindAll(".mud-message-box button").Single(b => b.TextContent.Contains("Löschen")).Click();
cut.WaitForAssertion(() => Assert.Equal([plan.Id], _api.Deleted));
cut.WaitForAssertion(() => Assert.NotEmpty(cut.FindAll(".fp-plan-empty")));
}
}